Family Office
Private wealth management platform — multi-agent AI, double-entry accounting, tax optimization, and estate planning in one local-first application.
Overview & Vision
The Family Office is a production-ready, local-first wealth management platform purpose-built for a single family: the Principal, spouse, and children. It combines a FastAPI web dashboard for human interaction with a LangGraph multi-agent AI backend for autonomous analysis and execution.
The core philosophy is human-in-the-loop for material decisions, AI for everything analytical. Any trade over $50,000, all gifts, and all tax elections are routed through an explicit approval queue before execution. The Principal retains final authority; the AI agents handle research, analysis, and recommendation.
Five Design Pillars
- Local-first: Runs entirely on
127.0.0.1. All data stays on-device in a SQLite file. No cloud dependency except LLM API calls. - Correctness over convenience: Full tax-lot tracking (FIFO/LIFO/HIFO), wash-sale detection, double-entry accounting journal — every position has an immutable audit trail.
- AI as advisor, not executor: The LangGraph supervisor routes questions to specialist agents. Agents can request trades but cannot execute them unilaterally above threshold.
- Real market data: Live prices via
yfinancebulk-download, cached in SQLite and refreshed daily by scripts. ASTABLE_VALUE_SYMBOLSguard prevents cash-equivalent tickers from being priced at their (incorrect) ETF equivalents. - Privacy mode: A single keyboard shortcut hides all dollar balances behind
•••••on every page — implemented with pure CSS, no re-render needed.
What the system manages
| Domain | Capability |
|---|---|
| Portfolio | Positions across all accounts; buy/sell execution; per-account cards; 120-day history charts; top movers |
| Tax | Tax-loss harvest candidates; YTD realized gain/loss; cost basis election simulation (FIFO vs HIFO vs LIFO); Section 475 analysis; Schedule D generation |
| Accounting | Double-entry journal with auto-entries for every trade; trial balance; income statement; balance sheet |
| Analysis | Portfolio volatility, Sharpe ratio, max drawdown, beta, VaR/CVaR; Markov regime detection (portfolio-blended + per-ticker); two-phase Monte Carlo with monthly deposits, withdrawal modeling, and survival rate; allocation drift vs investment profile |
| Estate | Gift tracking with annual exclusion / lifetime exemption calculation; GRAT simulation across growth scenarios; ownership tree |
| Daily Brief | Morning / Evening report with 10 macro indices, 11 sector ETFs, and per-position portfolio day impact; narrative auto-generated by the service layer |
| AI Chat | General LangGraph supervisor + direct portfolio advice endpoint with live context injection |
Tech Stack
| Category | Library | Version | Role |
|---|---|---|---|
| Web framework | FastAPI | ≥0.115 | ASGI app, routing, dependency injection |
| ASGI server | uvicorn[standard] | ≥0.34 | Production-grade server with hot reload |
| Templating | Jinja2 | ≥3.1 | Server-side HTML rendering; 31 templates |
| ORM | SQLAlchemy | ≥2.0 | Declarative models; session management |
| Migrations | Alembic | ≥1.14 | Schema versioning (table creation on startup) |
| Validation | Pydantic v2 | ≥2.10 | Request/response schemas; settings |
| Agent graph | LangGraph | ≥0.3 | Multi-agent state machine with message accumulation |
| Agent supervisor | langgraph-supervisor | ≥0.0.7 | Supervisor-worker pattern; routing logic |
| LLM (primary) | langchain-anthropic | ≥0.3 | Claude claude-sonnet-4-5 via Anthropic API |
| LLM (fallback) | langchain-openai | ≥0.3 | OpenAI fallback if Anthropic key absent |
| Market data | yfinance | ≥0.2 | Daily price fetching; bulk download for risk metrics |
| Data analysis | pandas / numpy | ≥2.2 / ≥2.0 | Return series construction; Monte Carlo simulation |
| HTTP client | httpx | ≥0.28 | Async HTTP (reserved for future external calls) |
| Encryption | cryptography | ≥44.0 | AES field encryption for SSN/tax ID columns |
| SSE | sse-starlette | ≥2.0 | Streaming support (declared, endpoints planned) |
| Testing | pytest / pytest-asyncio | ≥8.0 | Service-layer unit tests |
| Linting | ruff | ≥0.8 | Fast Python linter |
| Database | SQLite (WAL mode) | built-in | All persistent state; WAL for concurrent reads |
| Frontend CSS | Tailwind CSS | CDN | Utility-first responsive layout |
| Frontend charts | Chart.js 4 | CDN | Portfolio history line charts; Monte Carlo fan charts; allocation donut |
| Frontend helpers | htmx 2.0.4 | CDN | Partial page updates (reserved for future use) |
Entry Point
cd family-office
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
The default port in config.py is 8000; the team runs on 8001 to avoid conflicts with other local services. Pass --port 8001 or set KFO_PORT=8001 in .env.
Project Structure
family-office/
├── .env # Active configuration (KFO_ prefixed env vars)
├── .env.example # Template with all available keys
├── pyproject.toml # Package metadata + all dependencies
├── app/
│ ├── main.py # FastAPI factory, router registration, startup hook
│ ├── config.py # Pydantic-settings: KFO_ env vars → Settings singleton
│ ├── dependencies.py # FastAPI DI: get_db(), get_settings()
│ ├── models/ # SQLAlchemy ORM (18 tables)
│ │ ├── base.py # DeclarativeBase, TimestampMixin, SoftDeleteMixin
│ │ ├── family.py # FamilyMember, FamilyEntity, Ownership
│ │ ├── account.py # Account, AccountTypeEnum (13 types)
│ │ ├── asset.py # Asset, AssetPrice, AssetClassEnum (12 classes)
│ │ ├── transaction.py # Transaction, TransactionTypeEnum (13 types)
│ │ ├── tax_lot.py # TaxLot, TaxLotDisposal, WashSaleAdjustment
│ │ ├── journal.py # ChartOfAccounts, JournalEntry, JournalLine
│ │ ├── expense.py # ExpenseCategory, Vendor, Expense
│ │ ├── approval.py # ApprovalPolicy, Approval, ApprovalStatusEnum
│ │ ├── estate.py # Gift, GRATSimulation
│ │ ├── report.py # GeneratedReport, ComplianceFlag
│ │ └── investment_profile.py # InvestmentProfile, RiskToleranceEnum
│ ├── schemas/ # Pydantic v2 request/response models
│ │ ├── common.py # HealthCheck, Pagination, ErrorResponse, DateRange
│ │ ├── portfolio.py # HoldingDetail, PortfolioSummary, TradeRequest
│ │ ├── tax.py # HarvestCandidate, TaxSummary, ElectionSimResult
│ │ ├── accounting.py # JournalEntryCreate, TrialBalance, IncomeStatement
│ │ ├── approval.py # ApprovalOut, ApprovalDecision
│ │ ├── estate.py # GiftCreate, GRATParams, OwnershipNode
│ │ ├── reporting.py # ReportRequest, ReportOut, ComplianceFlagOut
│ │ └── analysis.py # RiskMetrics, DriftAnalysis, MonteCarloResult
│ ├── services/ # Business logic (pure Python, no FastAPI)
│ │ ├── db.py # Engine singleton, WAL mode, session factory
│ │ ├── portfolio_service.py # Trade execution, tax-lot lifecycle, wash-sale detection
│ │ ├── market_data.py # yfinance fetching, STABLE_VALUE_SYMBOLS guard, cache
│ │ ├── tax_service.py # Harvest candidates, tax summary, election simulation
│ │ ├── accounting_service.py # Double-entry journal, auto-journaling, statements
│ │ ├── approval_service.py # HITL workflow: create, decide, expire
│ │ ├── estate_service.py # Gifts, GRAT simulation, ownership tree
│ │ ├── reporting_service.py # Report generation, Schedule D, compliance scan
│ │ ├── morning_brief_service.py # AM/PM cached market brief
│ │ ├── markov_regime_service.py # Markov chain regime detection (portfolio + per-ticker)
│ │ └── analysis_service.py # Risk metrics, Monte Carlo, profiles, drift
│ ├── agents/ # LangGraph multi-agent system
│ │ ├── state.py # FamilyOfficeState TypedDict
│ │ ├── supervisor.py # build_supervisor() → compiled LangGraph
│ │ ├── {domain}_agent.py # 6 specialist agents (portfolio/tax/accounting/...)
│ │ └── tools/ # Tool factories for each agent (7 modules)
│ ├── api/ # FastAPI routers (11 modules)
│ │ ├── dashboard.py # GET /
│ │ ├── portfolio.py # /portfolio/*
│ │ ├── tax.py # /tax/*
│ │ ├── accounting.py # /accounting/*
│ │ ├── reports.py # /reports/*
│ │ ├── estate.py # /estate/*
│ │ ├── approvals.py # /approvals/*
│ │ ├── agent_chat.py # /agent/invoke, /agent/portfolio-advice
│ │ ├── analysis.py # /analysis/*
│ │ ├── prices.py # /prices/*
│ │ └── health.py # /health
│ └── templates/ # Jinja2 templates (31 files)
│ ├── base.html # Layout: nav, privacy toggle, chat panel
│ ├── wiki/index.html # ← This document
│ ├── dashboard.html
│ ├── portfolio/ # 10 templates
│ ├── tax/ # 3 templates
│ ├── accounting/ # 3 templates
│ ├── reports/ # 1 template
│ ├── estate/ # 3 templates
│ ├── approvals/ # 2 templates
│ └── analysis/ # 7 templates + _subnav partial
├── scripts/
│ ├── update_prices.py # Bulk yfinance price refresh (run daily)
│ ├── update_schwab_000.py # Import Schwab ...000 account CSV positions
│ ├── update_schwab_645.py # Import Schwab ...645 account CSV positions
│ ├── update_schwab_912.py # Import Schwab ...912 account CSV positions
│ ├── update_fidelity.py # Import Fidelity account positions
│ └── update_529.py # Import 529 plan positions
├── tests/
│ └── test_services/ # pytest unit tests for service layer
└── data/
└── family_office.db # SQLite database file (created at runtime)
Configuration
All settings use the KFO_ environment variable prefix, loaded from .env via pydantic-settings. The Settings singleton is instantiated once at module import and shared across the entire application via get_settings().
The .env file path is resolved absolutely using Path(__file__).parent.parent / ".env" to avoid working-directory-dependent failures when uvicorn is started from different locations.
| Setting | Env Var | Default | Notes |
|---|---|---|---|
| db_path | KFO_DB_PATH | data/family_office.db | Relative to project root |
| db_passphrase | KFO_DB_PASSPHRASE | "" | When set, the database is opened through SQLCipher (AES-256 at rest). Empty = plaintext. See §19. |
| anthropic_api_key | KFO_ANTHROPIC_API_KEY | "" | Required for AI features |
| openai_api_key | KFO_OPENAI_API_KEY | "" | Fallback LLM if Anthropic absent |
| llm_provider | KFO_LLM_PROVIDER | anthropic | |
| llm_model | KFO_LLM_MODEL | claude-sonnet-4-5 | Override to any Anthropic model ID |
| market_data_cache_ttl_minutes | KFO_MARKET_DATA_CACHE_TTL_MINUTES | 15 | Price cache age before re-fetch |
| trade_approval_threshold | KFO_TRADE_APPROVAL_THRESHOLD | 50000 | Dollar amount above which trades need approval |
| gift_approval_threshold | KFO_GIFT_APPROVAL_THRESHOLD | 18000 | Annual exclusion trigger (2026 IRS limit) |
| host | KFO_HOST | 127.0.0.1 | Loopback by default; set 0.0.0.0 to reach the app from other devices (set an access code first). See §19. |
| port | KFO_PORT | 8000 | Desktop launcher auto-picks a free port if this is taken |
| field_encryption_key | KFO_FIELD_ENCRYPTION_KEY | "" | AES key for SSN/account number columns |
| access_code | KFO_ACCESS_CODE | "" | Empty = open (local-first). Set to require sign-in at /login on every request — used when exposed on a network. |
| master_password | KFO_MASTER_PASSWORD | "" | Set by the desktop launcher. Fills db_passphrase (always) and access_code (only when host is 0.0.0.0). One secret, both jobs. |
| session_max_age_days | KFO_SESSION_MAX_AGE_DAYS | 30 | Lifetime of a /login session cookie |
Minimal .env
KFO_ANTHROPIC_API_KEY=sk-ant-api03-...
KFO_LLM_MODEL=claude-sonnet-4-5
The /agent/portfolio-advice endpoint has a three-tier API key resolution: (1) settings.anthropic_api_key, (2) os.environ.get("ANTHROPIC_API_KEY"), (3) direct file-read of .env. A 401 error means all three resolved to an empty or invalid value.
Database Schema
The database is a single SQLite file (data/family_office.db) with WAL mode enabled, foreign key enforcement, and a 5-second busy timeout. Tables are created via Base.metadata.create_all() on startup — no migration runner is required for a fresh install.
Entity Relationship Overview
│
┌─────────┴──────────────┐
transactions tax_lots
│ │
journal_entries tax_lot_disposals
│ │
journal_lines wash_sale_adjustments
│
chart_of_accounts
approvals ──gates──▶ transactions + gifts + journal_entries
assets ──priced by──▶ asset_prices
Core Tables
family_members
| Column | Type | Notes |
|---|---|---|
| id | Integer PK | |
| first_name, last_name | String(100) | Property: full_name |
| date_of_birth | Date | nullable |
| ssn_encrypted | String(500) | AES-encrypted SSN |
| relationship | String(50) | "principal", "spouse", "child" |
| String(200) | nullable | |
| is_active | Boolean | default True |
accounts
| Column | Type | Notes |
|---|---|---|
| id | Integer PK | |
| name | String(200) | e.g. "Fidelity 401(k) ...797" |
| account_type | AccountTypeEnum | brokerage, ira_traditional, ira_roth, 401k, trust, bank_checking, bank_savings, real_estate, private_equity, crypto, hsa, 529, other |
| institution | String(200) | nullable |
| account_number_encrypted | String(500) | nullable, AES-encrypted |
| is_taxable | Boolean | default True — controls harvest candidate inclusion |
| primary_owner_id | FK → family_members | nullable |
| tax_entity_id | FK → family_entities | nullable |
assets + asset_prices
| Column | Type | Notes |
|---|---|---|
| symbol | String(20) | nullable (CITs like 14022L645 have no ticker) |
| name | String(300) | Full descriptive name |
| asset_class | AssetClassEnum | us_equity, intl_equity, fixed_income, real_estate, private_equity, venture_capital, hedge_fund, commodity, crypto, cash, alternative, other |
| cusip / isin | String(20) | nullable — used for CIT identification |
| is_publicly_traded | Boolean | False → skip yfinance; use manual price |
| is_qsbs_eligible | Boolean | Tracks Section 1202 QSBS eligibility |
| asset_prices: one row per (asset_id, price_date). Unique constraint prevents duplicates. Source field: "yfinance", "manual", "fidelity_screenshot". | ||
tax_lots — the core tax-tracking unit
| Column | Type | Notes |
|---|---|---|
| acquisition_date | Date | Determines long-term (≥366 days) vs short-term |
| original_quantity | Numeric(18,8) | Immutable — never decremented |
| remaining_quantity | Numeric(18,8) | Decremented on sells; 0 triggers is_closed=True |
| cost_basis_per_unit | Numeric(18,6) | May be adjusted upward by wash-sale disallowed losses |
| original_cost_basis_per_unit | Numeric(18,6) | Immutable — audit reference |
| is_wash_sale_adjusted | Boolean | True if basis was ever adjusted |
| wash_sale_disallowed_loss | Numeric(18,2) | Cumulative disallowed loss added to this lot's basis |
| is_closed | Boolean, indexed | Fast filter: WHERE is_closed = False |
approvals — the HITL gate
| Column | Type | Notes |
|---|---|---|
| status | ApprovalStatusEnum | pending → approved | rejected | expired |
| action_type | String(100) | trade, gift, journal_entry, tax_election |
| action_payload | Text (JSON) | Full action detail for exact replay after approval |
| expires_at | DateTime | Auto-set from policy; trades expire in 72h, gifts/elections in 168h |
| langgraph_thread_id | String(200) | nullable — stored for future agent-resume capability |
Chart of Accounts (27 accounts, seeded on first run)
| Code range | Type | Examples |
|---|---|---|
| 1010–1900 | Asset | Cash (1010), Investments (1200), Real Estate (1400) |
| 2010–2900 | Liability | Accounts Payable (2010), Margin Loan (2100) |
| 3010–3900 | Equity | Capital Contributions (3010), Retained Earnings (3500) |
| 4100–4900 | Revenue | Realized Gains (4100), Dividend Income (4200), Interest (4300) |
| 5010–6900 | Expense | Mgmt Fees (5010), Advisory Fees (5100), Tax Prep (6100) |
Services Layer
All business logic lives in app/services/. Services are plain Python classes with no FastAPI dependency — they accept a SQLAlchemy Session and return Pydantic schemas or ORM objects. This makes them directly usable from both API routes and LangGraph agent tools.
PortfolioService
- get_summary(account_id=None) — Aggregates all open
TaxLotrecords, fetches current prices, returnsPortfolioSummarywith market values, cost basis, unrealized P&L, YTD realized gains, and allocation percentages. - execute_buy() — Creates
Transaction(BUY) +TaxLot. Fees are folded into cost basis:cpu = (qty × price + fees) / qty. - execute_sell() — Creates
Transaction(SELL), selects lots via_select_lots(method), createsTaxLotDisposalrecords withrealized_gain_loss, decrements lot quantities, calls_check_wash_sale()on any loss disposal. - _select_lots(method) — Supports FIFO (oldest first), LIFO (newest first), HIFO (highest cost first), and specific lot ID.
- _check_wash_sale(disposal, sale_date) — Scans for any purchase of the same asset within ±30 days. On match: marks disposal
is_wash_sale=True, increments replacement lot'scost_basis_per_unitby the disallowed loss amount, createsWashSaleAdjustment. - get_portfolio_history(days=90) — Returns date-labeled total and per-account values using stored
AssetPricerecords with forward-fill for missing dates. - get_top_movers(limit=10) — Top gainers and losers by unrealized P&L percent.
MarketDataService
- STABLE_VALUE_SYMBOLS — Hardcoded set:
{CASH, SWTXX, SWVXX, VMFXX, SPAXX, FDRXX, FDLXX, ...}. These always return $1.00 without querying yfinance. Critical correctness guard — "CASH" is the PGIM Ultra Short Bond ETF on yfinance and would otherwise be priced at ~$86. - get_current_price(asset) — Checks
asset_pricescache first (max 3 days old); falls back to yfinance fetch on miss. Private assets always use manual price. - _fetch_and_cache(asset) —
yf.Ticker(symbol).history(period="2d"), upserts intoasset_prices. - bulk_update_prices() — Iterates all public non-stable assets; skips stable-value symbols.
TaxService
- find_harvest_candidates(min_loss, account_id) — Scans open lots in taxable accounts, computes unrealized loss at current price, flags wash-sale risk (purchase within 30 days), estimates tax savings at
(37% ST or 20% LT) + 3.8% NIIT. Sorted by loss magnitude. - get_tax_summary(tax_year) — Queries disposals for realized gains/losses; queries transactions for dividend and interest income; queries deductible expenses. Estimates liability by splitting income into ST (37%) and LT+qualified-div (20%+3.8% NIIT) buckets. Expenses offset ST income first. Loss carryforward = max(0, |net_loss| − $3,000) per IRC §1211(b).
- simulate_election(election_type) —
"cost_basis_method": simulates FIFO/HIFO/LIFO."section_475": describes mark-to-market trader status. HIFO flagged asrecommended=True.
AccountingService
- create_journal_entry(data) — Validates debits == credits within $0.01; creates
JournalEntry+JournalLinechildren. All entries immediately posted. - auto_journal_for_trade(transaction) — BUY: debit Investments (1200), credit Cash (1010). SELL: debit Cash, credit Investments, debit/credit Realized Gain/Loss (4100) based on disposal records. DIVIDEND: debit Cash, credit Dividend Income (4200).
- get_trial_balance(as_of_date) — Sums all posted lines per COA account up to date.
- get_income_statement(start, end) — Revenue and expense balances for the period.
- get_balance_sheet(as_of_date) — Asset, liability, and equity balances from inception.
- seed_chart_of_accounts() — Creates the standard 27-account COA on first run.
ApprovalService
- check_needs_approval(action_type, amount) — Queries active policies for trigger type; returns matching
ApprovalPolicyif threshold met. - create_approval() — Creates PENDING approval with full JSON
action_payload; setsexpires_at. - decide(id, decision) — Sets APPROVED or REJECTED; records
decision_at. - expire_stale() — Bulk-marks past-
expires_atpending approvals as EXPIRED; called on every approvals page load.
EstateService
- record_gift() — Allocates gift between annual exclusion (per donor→recipient pair per year, up to $18,000) and lifetime exemption (~$13.61M). Charitable gifts (
is_charitable=True) skip both allocations entirely — no annual exclusion or lifetime exemption consumed, no Form 709 required. Annual exclusion tracking filters explicitly to the matching recipient (including NULL→NULL for entity/external gifts) to prevent cross-recipient aggregation errors. Form 709 auto-set when non-charitable excess exists. - simulate_grat(params) — Runs GRAT annuity math for each growth rate scenario. Compounds balance annually, subtracts annuity payment, calculates remainder. Tax savings estimated at 40% estate tax rate. Persists each scenario as a
GRATSimulationrow. - get_ownership_tree() — Returns nested
OwnershipNodetree for all active family members with linked accounts and percentages.
ReportingService
- generate_quarterly_report() — Combines portfolio summary + tax summary, serializes to JSON, saves as
GeneratedReport. - generate_schedule_d_data(tax_year) — Produces IRS Form 8949 rows with symbol, acquisition/sale dates, proceeds, cost basis, G/L, ST/LT classification, and wash-sale adjustment amounts.
- scan_compliance() — Checks for wash-sale disposals lacking a
ComplianceFlag; creates new flags as needed.
MarkovRegimeService
Fits a 3-state (Bull / Sideways / Bear) Markov chain to price history using 20-day cumulative return labeling. Thresholds: Bull >+5%, Bear <−5%, Sideways otherwise. Transition matrix uses Laplace smoothing (ε = 0.01). Stationary distribution computed via left eigenvector of Tᵀ with power-iteration fallback.
- get_portfolio_and_ticker_regimes(symbols, weights) — Single
yf.download(period="2y")call for all holding symbols. Computes per-ticker regimes AND a market-value-weighted composite return series ("Portfolio (N positions)") for a portfolio-blended regime. Returns(portfolio_regime, ticker_regime_dict). - get_regime(symbol, lookback_days, prices=None) — Fits chain for one symbol; accepts a pre-computed price series to avoid redundant downloads.
- RegimeState schema fields:
current_regime,persist_pct,one_step(T¹),five_step(T⁵),stationary,signal_score(P(Bull)−P(Bear), −1.0 to +1.0),lookback_days.
API Routes
All routes return HTML (Jinja2-rendered) except /health, /agent/*, /prices/*, and the Monte Carlo JSON endpoint.
Dashboard
| Method | Path | Description |
|---|---|---|
| GET | / | KPI cards (AUM, unrealized P&L, YTD, pending approvals), asset allocation bars, top holdings table, quick links |
Portfolio /portfolio/*
| Method | Path | Description |
|---|---|---|
| GET | /portfolio/ | All positions with market values, unrealized P&L, AI Investment Advisor panel |
| GET | /portfolio/accounts-view | Per-account card breakdown with colored header gradient |
| GET | /portfolio/trends | 120-day portfolio history charts, allocation donut, top gainers/losers, per-account bar chart |
| GET | /portfolio/trade | Buy/sell trade form |
| POST | /portfolio/trade | Execute buy/sell — routes to approval queue if total ≥$50k |
| GET | /portfolio/edit/{asset_id}/{account_id} | Edit position quantity/cost basis |
| POST | /portfolio/update | Update position |
| POST | /portfolio/delete/{asset_id}/{account_id} | Soft-close all lots for position |
| GET | /portfolio/accounts | Account list management |
| GET | /portfolio/assets | Asset list management |
Analysis /analysis/*
| Method | Path | Description |
|---|---|---|
| GET | /analysis/morning?refresh=0 | Daily Brief — AM or PM session, indices, sectors, portfolio day impact, narrative. ?refresh=1 bypasses cache. |
| GET | /analysis/ | Risk overview — key metrics + active profile drift summary |
| GET | /analysis/risk?lookback=252 | Detailed risk: volatility, Sharpe, max drawdown, beta, VaR/CVaR (95%/99%), per-position risk contribution, correlation heatmap tab, TWR tab |
| GET | /analysis/api/correlation?lookback_days=N | JSON — N×N Pearson correlation matrix of daily returns for all holdings with price history |
| GET | /analysis/api/returns | JSON — Time-Weighted Return: 1yr, 3yr, 5yr, inception (annualised) |
| GET | /analysis/profiles | Investment profile list — activate, create custom, delete |
| POST | /analysis/profiles/{id}/activate | Set active profile (deactivates all others) |
| GET | /analysis/rebalance | Drift analysis vs active profile; rebalance action list with dollar amounts |
| GET | /analysis/monte-carlo?years=&simulations=&goal= | Two-phase Monte Carlo page — accumulation fan + distribution fan, monthly deposits, Markov regime conditioning, withdrawal rate & years, survival rate. New params: monthly_contribution, withdrawal_rate, withdrawal_years |
Tax /tax/*
| Method | Path | Description |
|---|---|---|
| GET | /tax/harvest | Tax-loss harvest candidates with estimated savings |
| GET | /tax/summary?tax_year= | Annual tax summary: gains/losses, income, deductions, estimated liability |
| GET | /tax/elections | Cost basis election simulation + Section 475 analysis |
Agent /agent/* — JSON endpoints
| Method | Path | Returns | Description |
|---|---|---|---|
| POST | /agent/invoke | {response: str} | Routes to LangGraph supervisor; supports all 6 specialist domains |
| POST | /agent/portfolio-advice | {response: str} | Direct Anthropic API call with full portfolio context embedded in system prompt. No tool calls needed — data is pre-loaded from server side. |
Prices /prices/* — JSON endpoints
| Method | Path | Description |
|---|---|---|
| POST | /prices/refresh | Bulk yfinance price fetch for all public, non-stable-value assets |
| GET | /prices/status | Price freshness: total public assets, updated today count, latest price date |
Templates & UI
All 31 Jinja2 templates extend base.html. Styling is entirely Tailwind CSS (CDN); charts use Chart.js 4. Sub-navigation within Portfolio and Analysis sections is handled by shared partials (_subnav.html).
base.html — Layout Shell
- Full-width dark slate navbar with links: Dashboard, Portfolio, Tax, Accounting, Reports, Estate, Analysis, Approvals
- Active nav item highlighted by matching
page_titleagainst a list of known values per section - Right side: privacy toggle button (eye icon) — see §09
- Bottom-right: floating AI chat bubble (slide-up panel, calls
/agent/invoke) - Pending approvals badge (red dot count) on the Approvals nav item
- Privacy mode CSS and early-apply
<head>script — see §09
Key Pages
| Template | Route | Notable Features |
|---|---|---|
| dashboard.html | / | 4 KPI cards, allocation bars, top-10 holdings table, tax summary, quick links |
| portfolio/overview.html | /portfolio/ | Full holdings table + AI Investment Advisor panel (collapsible, 6 prompt chips, live context) |
| portfolio/accounts_overview.html | /portfolio/accounts-view | Per-account gradient cards with P&L coloring; top-3 holdings per account |
| portfolio/trends.html | /portfolio/trends | Line chart (total + by-account toggle); allocation donut; gainers/losers with bar indicators; account bar chart |
| analysis/morning.html | /analysis/morning | Session badge (AM/PM), macro indices grid, sector heatmap sorted by performance, portfolio day-impact table, narrative paragraph |
| analysis/monte_carlo.html | /analysis/monte-carlo | Fan chart with P10–P90 bands for current + target allocation; goal line overlay; probability of reaching goal |
| analysis/risk.html | /analysis/risk | Portfolio-level vol/Sharpe/drawdown/beta; per-position volatility and beta; concentration (HHI) and asset-class risk breakdown |
| approvals/pending.html | /approvals/ | Pending approval cards with full action detail; Approve/Reject buttons; auto-expire on page load |
| estate/grat_sim.html | /estate/grat | Multi-scenario table (4–10% growth rates) with remainder values color-coded green when positive |
AI Investment Advisor Panel (portfolio/overview.html)
The collapsible violet panel at the bottom of the holdings page injects a live portfolio snapshot into the PORTFOLIO_CONTEXT JavaScript constant (server-side via {{ portfolio_context | tojson }}). When the user clicks Ask, it POSTs to /agent/portfolio-advice with the message and this pre-loaded context — no additional network call for data needed. See §10 for the direct Anthropic path.
Privacy Mode
Privacy mode replaces every dollar balance on every page with •••••. It is implemented entirely in CSS with no re-render, no additional API call, and no flash of unprotected content.
Implementation
Every monetary value in every template is wrapped in a <span class="bal">. The CSS in base.html targets .privacy-mode .bal:
/* Applied to <html> before first paint — no flash */
.privacy-mode .bal {
font-size: 0 !important;
letter-spacing: 0 !important;
white-space: nowrap;
display: inline-block;
min-width: 3.5ch;
}
.privacy-mode .bal::after {
content: "•••••";
font-size: 0.9rem;
letter-spacing: 0.06em;
color: inherit;
font-weight: inherit;
}
/* Size variants for larger headings */
.privacy-mode .bal-lg::after { font-size: 1.3rem; }
.privacy-mode .bal-xl::after { font-size: 1.8rem; }
Flash prevention
An inline <script> in the <head> runs before the first paint:
if (localStorage.getItem('kfo_privacy') === '1') {
document.documentElement.classList.add('privacy-mode');
}
Toggle mechanics
function togglePrivacy() {
const on = document.documentElement.classList.toggle('privacy-mode');
localStorage.setItem('kfo_privacy', on ? '1' : '0');
_syncPrivacyUI(on); // swaps eye/eye-off icon and Hide/Show label
}
Coverage
bal spans are applied across all 31 templates covering: Dashboard KPI cards, portfolio holdings table, account cards, trends KPI row, daily brief indices/holdings, Monte Carlo outcome cards, tax summary figures, tax harvest amounts, GRAT simulation values, gifting table, accounting journal totals, trial balance, financial statements, and edit-position cost basis displays.
The technique is robust because it uses CSS class toggling on <html> — a single DOM write. No JavaScript needs to iterate the DOM to find balance elements. No page navigation clears the state (localStorage persists across pages).
AI Agent System
Architecture — Two Paths
The AI system has two distinct invocation paths:
- LangGraph Supervisor (
/agent/invoke) — Full multi-agent graph for general questions. Routes to one of six specialist agents, each with domain-specific tools that call the service layer. - Direct Anthropic Call (
/agent/portfolio-advice) — Bypasses LangGraph entirely for portfolio questions. Live data is pre-embedded in the system prompt; no tool calls needed. Significantly faster for portfolio-specific queries.
LangGraph Supervisor Graph
↓
build_supervisor() → compiled LangGraph with FamilyOfficeState
↓
Supervisor LLM (Claude claude-sonnet-4-5 or OpenAI fallback)
↙ routes to one of six specialist agents ↘
portfolio_agent tax_agent accounting_agent
reporting_agent estate_agent analysis_agent
↓
Agent executes tool calls → service layer → SQLite
↓
Supervisor formulates response
↓
JSON {response: str}
Shared State
FamilyOfficeState (app/agents/state.py) is a TypedDict with:
messages— accumulates via LangGraph'sadd_messagesreducerpending_approvals: list[dict]— carries approval context across agentscurrent_agent: str | None— tracks active agentcontext: dict— session metadata (date ranges, entity selection)
Specialist Agents & Tools
| Agent | Tools | Has Approval Tools? |
|---|---|---|
| portfolio_agent | get_portfolio_summary, get_market_quote, get_open_tax_lots, execute_buy_trade, execute_sell_trade | Yes |
| tax_agent | find_harvest_candidates, get_tax_summary, simulate_tax_election | Yes |
| accounting_agent | get_trial_balance, get_income_statement, get_balance_sheet, get_journal_entries | No |
| reporting_agent | generate_quarterly_report, generate_schedule_d, check_compliance, get_compliance_flags | No |
| estate_agent | get_ownership_tree, get_gift_history, simulate_grat, get_annual_exclusion_remaining, get_lifetime_exemption_remaining | Yes |
| analysis_agent | get_risk_metrics, get_allocation_drift, list_investment_profiles, set_investment_profile, run_monte_carlo_simulation | No |
Portfolio Advice — Direct Path
The PORTFOLIO_ADVISOR_SYSTEM prompt establishes the advisor role. A live snapshot is appended at request time:
system = PORTFOLIO_ADVISOR_SYSTEM
if context.strip():
system += f"\n\n--- LIVE PORTFOLIO SNAPSHOT ---\n{context.strip()}\n--- END SNAPSHOT ---"
response = client.messages.create(
model=settings.llm_model,
max_tokens=1024,
system=system,
messages=[{"role": "user", "content": message}],
)
The PORTFOLIO_CONTEXT injected server-side includes: total AUM, cost basis, unrealized P&L, allocation by asset class (%), and a full JSON array of all holdings with symbol, account, quantity, cost, market value, unrealized G/L, and weight.
Fallback Supervisor
If neither KFO_ANTHROPIC_API_KEY nor KFO_OPENAI_API_KEY is configured, build_supervisor() returns a FallbackSupervisor that returns a plain explanation message. The web UI remains fully functional for all non-AI features.
Daily Market Brief
The morning (or evening) brief aggregates macro market data and per-position portfolio impact. It is session-cached at the module level — the expensive yfinance bulk download runs at most twice per trading day regardless of how many times the page is loaded.
Session Keying
| Time (ET) | Cache Key | Report Title | Next Update |
|---|---|---|---|
| Midnight – 4:05 PM | YYYY-MM-DD-AM | Morning Brief | "at market close (4:05 PM ET)" |
| 4:05 PM – Midnight | YYYY-MM-DD-PM | Evening Report | "at market open tomorrow" |
| Saturday / Sunday | YYYY-MM-DD-AM | Weekend Summary | "at market open Monday" |
Data Fetched per Session
| Category | Symbols |
|---|---|
| Macro indices | SPY, QQQ, DIA, IWM, ^VIX, TLT, GLD, USO, UUP, BTC-USD |
| Sector ETFs | XLK, XLV, XLF, XLE, XLI, XLY, XLP, XLU, XLB, XLRE, XLC |
| Portfolio positions | All currently open lots — uses stored AssetPrice for yesterday's close |
Portfolio Day Impact
For each open lot, the service fetches today's price and yesterday's price from asset_prices, computes day_change = (today - yesterday) / yesterday × 100, and multiplies by market value to get the dollar impact. The portfolio total day P&L is the sum of all position impacts.
Narrative Generation
The _build_narrative() method generates 5–7 English sentences covering:
- S&P direction and magnitude
- VIX level (elevated/low)
- Bond/equity relationship (TLT direction vs SPY)
- Top sector leader and laggard
- Gold / Bitcoin moves if notable (>1%)
- Portfolio day change and direction
Markov Regime Detection
Each time the brief is built (on cache miss or force-refresh), MarkovRegimeService.get_portfolio_and_ticker_regimes() is called with all holding symbols and their market-value weights. A single yf.download(period="2y") fetches 2 years of price data for all holdings simultaneously.
Portfolio-blended regime: Rather than using a proxy like SPY, the service builds a weighted composite daily return series from all holdings and runs the Markov chain on that series. This reflects your actual portfolio risk exposure — critical for portfolios with significant non-equity exposure (bonds, gold, crypto, alternatives).
| Regime | Label Threshold (20-day cumulative return) | Monte Carlo adjustment (yr 1) |
|---|---|---|
| 🐂 Bull | > +5% | +2% expected return, −10% volatility |
| ↔ Sideways | −5% to +5% | No adjustment |
| 🐻 Bear | < −5% | −3% expected return, +20% volatility |
Per-ticker badges: Each row in the "All Positions" holdings table shows a micro-badge (↑ Bull / ↓ Bear / ~ Sideways) next to the symbol, with a tooltip showing regime name and signal score.
Regime detail panel: When a portfolio regime is available, the brief page shows a 3-column bar chart comparing Long-Run Equilibrium, Tomorrow (T¹), and 1-Week (T⁵) probability distributions across Bull / Sideways / Bear states.
Narrative bullet: The narrative paragraph includes a regime sentence, e.g. "🐂 Markov regime: Bull (signal +0.42). Long-run equilibrium: 55% Bull / 30% Sideways / 15% Bear."
Regime data is only recomputed when the brief cache is busted. Clicking Refresh Now on the Daily Brief page triggers a full rebuild including fresh Markov fits. Monte Carlo always recomputes regime live (no cache).
Force Refresh
Append ?refresh=1 to /analysis/morning to bypass the session cache and fetch fresh data immediately. Useful after a big mid-session market move.
Risk & Portfolio Analysis
Risk Metrics (AnalysisService)
| Metric | Calculation | Notes |
|---|---|---|
| Portfolio Volatility | portfolio_std × √252 | Annualized, 252-day basis |
| Sharpe Ratio | (annualized_return − risk_free) / volatility | Default risk-free = 5% |
| Max Drawdown | min((V_t − peak_t) / peak_t) | Returns peak date, trough date |
| Portfolio Beta | cov(portfolio, SPY) / var(SPY) | SPY fetched over same lookback window |
| HHI Concentration | Σ(w_i%)² | DOJ convention — weights as percentages; 100% single-holding = 10,000 |
| Per-position Beta | cov(pos_returns, SPY) / var(SPY) | |
| Risk Contribution % | w_i × Cov(r_i, r_p)_annual / Var(r_p)_annual × 100 | Exact Euler decomposition — contributions sum to 100% |
| 1-Day VaR (95%) | |5th-percentile daily return| × portfolio_AUM | Historical simulation, no normality assumption |
| CVaR / ES (95%) | |mean(returns below VaR)| × portfolio_AUM | Expected Shortfall — captures tail severity beyond VaR |
| VaR / CVaR (99%) | Same as above at 1st percentile | Worst-1% of days expected loss |
Performance optimization: _bulk_daily_returns() issues a single yf.download() call for all symbols simultaneously — orders of magnitude faster than per-ticker calls. It first checks DB cache for stored asset_prices (with a ±99% sanity check to reject corrupted history), then fetches only the symbols with cache misses.
Asset Class Assumptions (for private/illiquid assets)
| Class | Annual Return | Volatility |
|---|---|---|
| us_equity | 10% | 16% |
| intl_equity | 8% | 18% |
| fixed_income | 4% | 5% |
| real_estate | 7% | 12% |
| private_equity | 12% | 22% |
| venture_capital | 15% | 30% |
| crypto | 15% | 60% |
| cash | 4% | 0.5% |
| commodity | 5% | 20% |
Correlation Matrix
Pairwise Pearson correlation of daily log-returns for all holdings with price history, via get_correlation_matrix(lookback_days=252). Returns an N×N CorrelationMatrix schema. Rendered on the Risk page's Correlation Heatmap tab using an HTML Canvas drawing — blue = positive correlation, red = negative, white = zero. Available as JSON at GET /analysis/api/correlation?lookback_days=N.
Time-Weighted Return (TWR)
get_return_metrics() chain-links daily sub-period returns (product of 1 + r_t) over 1-year, 3-year, 5-year, and since-inception windows, then annualises via (cumulative + 1)^(1/years) − 1. Requires holdings price history in the DB; private/illiquid positions are excluded. Results exposed at GET /analysis/api/returns and displayed in the "Return Metrics (TWR)" tab of the Risk page.
Monte Carlo Simulation — Two-Phase
Log-normal Geometric Brownian Motion (GBM) with annual steps, using Ito's lemma correction: drift = μ − 0.5σ². Portfolio μ and σ are weighted-average asset-class parameters (uncorrelated assumption). The simulation runs two phases sequentially: Accumulation then optionally Distribution.
Accumulation Phase
Standard GBM with optional monthly deposit model using a mid-year approximation (deposits assumed to arrive uniformly throughout the year, earning on average √gf in growth):
drift = port_mu - 0.5 * port_sigma²
growth_factors = exp( rng.normal(drift, port_sigma, (num_sims, years)) )
# With monthly deposits (mid-year approximation):
for y in range(years):
gf = growth_factors[:, y]
port = port * gf + annual_contrib * sqrt(gf) # √gf = ~6-month growth
# Without deposits:
cumulative = cumprod(growth_factors, axis=1) * initial_value
Markov Regime Conditioning
The accumulation phase adjusts Year 1 and Year 2 parameters based on the current portfolio regime (blend schedule: 100% regime in Year 1, 50% in Year 2, base assumptions from Year 3 onward):
| Regime | μ adjustment (yr 1) | σ adjustment (yr 1) |
|---|---|---|
| Bull | +2% | ×0.90 (−10%) |
| Sideways | 0 | ×1.00 |
| Bear | −3% | ×1.20 (+20%) |
Distribution / Withdrawal Phase
Activated when withdrawal_rate > 0 and withdrawal_years > 0. The dollar withdrawal is computed once per simulation from its own ending accumulation value — making it constant across all distribution years within that path (classic "4% rule" framing). No regime conditioning applies in this phase.
# Per-simulation constant annual withdrawal (4% rule)
annual_wdraw = final_vals * (withdrawal_rate / 100.0)
# Distribution loop — grow first, then withdraw; floor at 0 (ruin)
for y in range(withdrawal_years):
port_d = maximum(port_d * dist_gf[:, y] - annual_wdraw, 0.0)
Survival rate = fraction of simulations where portfolio > 0 at end of distribution phase. Color-coded: ≥90% green (highly sustainable), ≥70% amber (moderate risk), <70% red (high ruin probability).
Retirement Timeline & Social Security
Optional age inputs make the projection age-aware. current_age + retirement_age drive the accumulation length (years = retirement − current). ssa_claiming_age (62–70) and ssa_monthly_fra (estimated benefit at Full Retirement Age 67) add Social Security as an income offset in the distribution phase.
The FRA estimate is adjusted for the claiming age via standard SSA rules (_ssa_claiming_factor): 62 ≈ 70%, 67 = 100%, 70 ≈ 124%. Each retirement year the smile-adjusted spending need is met by SSA first, and the portfolio draws only the remainder — so the pre-SSA years (the bridge) are funded 100% by the portfolio:
ssa_per_year[y] = ssa_annual if retirement_age + y >= ssa_claiming_age else 0
spending_need = annual_wdraw * smile_mults[y]
portfolio_draw = maximum(spending_need - ssa_per_year[y], 0.0)
port_d = maximum(port_d * dist_gf[:, y] - portfolio_draw, 0.0)
The model is nominal (no COLA / inflation indexing, consistent with the flat-withdrawal design). Adding SSA raises the survival rate and shortens portfolio drawdown. Result fields: ssa_claiming_age, ssa_monthly_benefit / ssa_annual_benefit (claiming-adjusted), ssa_fra_factor, ssa_total_benefit, bridge_years, net_draw_after_ssa. The chart labels the x-axis by age and marks the SSA start year. Inputs flow via /analysis/monte-carlo query params.
Not yet modeled: per-account-type buckets (taxable / traditional / Roth) and withdrawal sequencing, taxes, Rule of 55, 72(t), Roth conversions, ACA/MAGI. SSA offsets the total draw but the engine is still a single pooled portfolio — this is the larger "Income Bridge Planner" roadmap item.
Chart — Retirement Anchor & Phase Boundary
The Chart.js fan chart shows two colored fans joined at the retirement year. The first point of the distribution (orange) fan is the retirement starting value — the exact same value as the last accumulation (blue) fan point. This anchors both fans at the same pixel and makes the slope change (contributions stop + withdrawals begin) visually obvious as a kink at the "Retirement" dashed divider line.
Each simulation's distribution phase starts from its own accumulation ending value — lower-outcome simulations carry their lower starting point all the way through distribution, so the P10 / median / P90 bands remain causally linked across phases (78% cross-percentile continuity at the first distribution year).
Result Fields
| Field | Description |
|---|---|
| final_median / p10 / p90 | End-of-accumulation portfolio values |
| probability_of_goal | % of simulations ending ≥ goal_amount |
| regime_context | "Bull" | "Sideways" | "Bear" | "Base" |
| monthly_contribution | Monthly deposit amount used in simulation |
| withdrawal_annual_amount | Median annual withdrawal (= median final_val × rate) |
| withdrawal_total | Median total withdrawn over distribution years |
| withdrawal_final_median / p10 / p90 | Portfolio values at end of distribution phase |
| portfolio_survival_rate | % of simulations with portfolio > 0 at end |
Investment Profiles
Five system profiles are seeded on first run. Only one can be active at a time.
| Profile | US Equity | Intl Equity | Fixed Income | Cash |
|---|---|---|---|---|
| Conservative | 20% | 10% | 55% | 15% |
| Moderate | 35% | 15% | 40% | 10% |
| Balanced (default active) | 45% | 20% | 25% | 5% |
| Growth | 60% | 25% | 10% | 5% |
| Aggressive | 70% | 20% | 5% | 5% |
Tax & Compliance
Tax Rates (2026 approximate)
| Rate Type | Value | Applies to |
|---|---|---|
| Short-term capital gains | 37% | ST gains + interest income (ordinary rate, top bracket) |
| Long-term capital gains | 20% | LT gains + qualified dividends (held ≥ 366 days) |
| Net Investment Income Tax (NIIT) | 3.8% | Added to LT rate for total 23.8% on investment income |
Tax Liability Calculation
get_tax_summary() splits income into two buckets and taxes each at its correct rate:
# Short-term bucket: ST gains + interest → 37%
st_taxable = (st_gains + st_losses) + interest
# Long-term bucket: LT gains + qualified dividends → 20% + 3.8% NIIT
lt_taxable = (lt_gains + lt_losses) + dividends
# Deductible expenses offset highest-rate income first (ST bucket)
st_after = max(0, st_taxable - expenses)
remaining_expenses = max(0, expenses - st_taxable)
lt_after = max(0, lt_taxable - remaining_expenses)
estimated_tax = st_after × 0.37 + lt_after × 0.238
# IRC §1211(b): first $3,000 of net loss deductible vs ordinary income; remainder carries forward
loss_carryforward = max(0, |net_loss| - 3000)
Tax-Loss Harvesting Logic
- Query all open
TaxLotrecords in taxable accounts (is_taxable=True) - Aggregate by (asset, account) — sum remaining quantities and cost basis per unit
- Fetch current price; compute unrealized loss = (current_price − cpu) × qty
- Flag wash-sale risk: check for any purchase of same asset within 30 days of today
- Estimate tax savings:
|loss| × (rate + NIIT)where rate = 37% if short-term, 20% if long-term - Return
HarvestCandidatelist sorted by loss magnitude (most negative first)
Wash-Sale Detection (at sell time)
↓ for each loss disposal
_check_wash_sale(disposal, sale_date)
↓
Was same asset purchased within sale_date ± 30 days?
↙ Yes No ↘
Mark disposal is_wash_sale=True No action
cpu += disallowed_loss / original_quantity (not remaining_qty)
Create WashSaleAdjustment audit record
Schedule D / Form 8949 Generation
Queries all TaxLotDisposal records for the tax year, producing rows with: symbol, description, acquisition date, sale date, proceeds, cost basis, wash sale adjustment, net gain/loss, and short/long-term classification. Output is stored as JSON in GeneratedReport.content_json.
Cost Basis Election Simulation
| Method | Description | Recommended? |
|---|---|---|
| FIFO | Oldest lots sold first — typically lowest remaining cost basis | No |
| HIFO | Highest cost basis lots sold first — minimizes realized gain | Yes |
| LIFO | Most recently acquired lots sold first | No |
| Section 475 | Mark-to-market trader status — all gains/losses ordinary | Informational only |
Estate Planning
Gift Tracking
- Annual exclusion (2026): $18,000 per donor-recipient pair. First $18k of any gift applies here.
- Lifetime exemption: ~$13.61M per donor. Amounts above the annual exclusion consume lifetime exemption.
- Form 709: Auto-flagged (
form_709_filed=True) when any gift exceeds the annual exclusion. - In-kind gifts: Assets can be gifted with a linked
asset_idanddonor_cost_basis. - Charitable gifts: Tracked separately with
is_charitable=True.
GRAT Simulation
A Grantor Retained Annuity Trust lets the grantor transfer appreciation above the Section 7520 hurdle rate to beneficiaries tax-free. The simulation:
- Compute annuity payment:
funding × (rate / (1 − (1 + rate)^(−term))) - For each growth rate scenario (default: 4%, 6%, 8%, 10%):
- Compound balance annually by growth rate
- Subtract annuity payment each year
- Remainder after term = wealth transferred tax-free (if >0)
- Tax savings estimate:
remainder × 40%(estate tax rate) - Rows with remainder >0 highlighted green — the trust "wins" when growth > 7520 rate
Ownership Tree
The ownership table maps family members and entities to accounts with percentage ownership. The get_ownership_tree() method returns a nested structure showing who owns what and at what percentage — useful for estate tax exposure analysis.
Human-in-the-Loop Approval System
The approval system is the central safety mechanism of the platform. No material financial action executes without passing through it.
Default Policies
| Policy Name | Trigger Type | Threshold | Expiry |
|---|---|---|---|
| Large Trade | trade_amount | $50,000 | 72 hours |
| All Gifts | gift | None (always) | 168 hours (7 days) |
| Tax Election | tax_election | None (always) | 168 hours |
| Manual Journal | journal_manual | $10,000 | 72 hours |
Approval Lifecycle
↓
ApprovalService.check_needs_approval(action_type, amount)
↙ Policy matches No match ↘
create_approval(payload JSON) Execute immediately
Redirect → /approvals/
[Principal reviews in web UI]
↙ APPROVED REJECTED ↘
_execute_approved_trade() Action discarded
or execute gift / journal
Action Payload
The full trade payload is stored as JSON in approval.action_payload. For a buy trade this includes: account_id, asset_id, quantity, price_per_unit, total_amount, notes. On approval, _execute_approved_trade() deserializes this payload and calls PortfolioService.execute_buy() with the original parameters — exactly as if the user had submitted it directly. No information loss.
Auto-Expiry
expire_stale() is called on every page load of GET /approvals/. Any pending approval past its expires_at timestamp is bulk-marked EXPIRED before the page renders. The Principal sees only currently valid approvals.
LangGraph resume not yet wired: The langgraph_thread_id column exists on Approval and sse-starlette is declared as a dependency — the structural foundation for resuming a paused agent graph after human approval is in place, but the agent-side resume logic is not yet implemented. Approved actions currently execute via direct service calls.
Data Flows
Trade Execution Flow
↓
Total amount calculated
qty × price + fees↙ total ≥ $50k total < $50k ↘
Create Approval (PENDING) PortfolioService.execute_buy/sell()
Redirect /approvals/ AccountingService.auto_journal_for_trade()
[wait for principal] Redirect /portfolio/
Principal approves
_execute_approved_trade(payload)
PortfolioService.execute_buy/sell()
AccountingService.auto_journal_for_trade()
Price Data Flow
↓
MarketDataService.bulk_update_prices()
↙ symbol in STABLE_VALUE_SYMBOLS?
Return $1.00 (no fetch) yf.download() bulk call
↓
Upsert into asset_prices
Update Asset.current_price
At page load:
PortfolioService.get_summary()
→ MarketDataService.get_current_price(asset)
→ check asset_prices cache (≤3 days old)
→ fetch from yfinance if stale
Portfolio Summary Assembly
- Query all
TaxLotWHEREis_closed = False(fast index scan) - Group by (asset_id, account_id) → aggregate remaining_quantity, weighted average CPU
- For each group: fetch current price → compute market_value, unrealized_gain_loss, weight_pct
- Query all
TaxLotDisposalfor current year WHEREdisposal_date ≥ Jan 1→ sum realized YTD - Aggregate market values by asset_class → allocation percentages
- Return
PortfolioSummaryPydantic object
CSV Import Flow (scripts)
↓
scripts/update_schwab_645.py (account-specific script)
↓
For each position row:
Close all existing open lots for account (set is_closed=True)
Create Transaction (BUY) with cost_basis / quantity
Create TaxLot with original and remaining quantity
Upsert AssetPrice for today
Scripts & Utilities
update_prices.py — Daily Price Refresh
The primary daily maintenance script. Queries all assets with is_publicly_traded=True, skips STABLE_VALUE_SYMBOLS, calls yf.download() for all remaining symbols in a single bulk call, upserts results into asset_prices, and prints a summary.
cd family-office
python3 scripts/update_prices.py
# Output: "Done. Updated 405/406 prices for 2026-05-16."
# (CTRA shows as "no data" — manually update via Yahoo Finance lookup)
Account Import Scripts
Each brokerage account has a dedicated import script that reads a CSV export and rebuilds the position data:
| Script | Account | Source Format |
|---|---|---|
| update_schwab_000.py | Schwab ...000 | Schwab CSV position export |
| update_schwab_645.py | Schwab ...645 (Contributory) | Schwab CSV position export |
| update_schwab_912.py | Schwab ...912 | Schwab CSV position export |
| update_fidelity.py | Fidelity accounts | Fidelity CSV + screenshot data |
| update_529.py | 529 plan | 529 plan export |
Manual Price Update Pattern
When yfinance cannot find a ticker (e.g. delisted CTRA), look up the current price on Yahoo Finance and update directly:
python3 - <<'EOF'
from app.services.db import get_factory
from app.models.asset import Asset, AssetPrice
from decimal import Decimal; from datetime import date
factory = get_factory(); db = factory()
asset = db.query(Asset).filter(Asset.symbol == "CTRA").first()
asset.current_price = Decimal("32.56")
db.add(AssetPrice(asset_id=asset.id, price_date=date.today(),
close_price=Decimal("32.56"), source="manual"))
db.commit(); print("Done")
EOF
Server Management
# Kill existing server on port 8001
lsof -ti :8001 | xargs kill -9
# Start fresh
cd family-office
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload \
>> /tmp/kfo-server.log 2>&1 &
# Check startup
tail -10 /tmp/kfo-server.log
Security & Data Model Notes
Network Security
- Loopback by default: Binds to
127.0.0.1. Can be exposed on a LAN withKFO_HOST=0.0.0.0(e.g. for phone/tablet access) — see §19. - Optional access gate: An
AccessCodeMiddleware(app/web.py) requires a shared passcode at/loginwhenaccess_codeis set; it auto-activates from the master password in network mode. Browser navigations redirect to/login; HTMX/API calls get a 401. The PWA shell (service worker, manifest, offline, help, health) stays open so the app can boot offline. - Stateless sessions: The session cookie is an HMAC of a constant message keyed by the access code — rotating the code invalidates all sessions; no server-side store.
- API keys in .env: Never committed. The
.gitignoreexcludes.env,data/,private/, and*.db; the release build aborts if any slip into a bundle.
Field-Level Encryption (Partial Implementation)
| Column | Model | Status |
|---|---|---|
| ssn_encrypted | family_members | Column defined, AES key in config, encrypt/decrypt wrappers not yet implemented |
| tax_id_encrypted | family_entities, vendors | Same — structural scaffold in place |
| account_number_encrypted | accounts | Same |
Database Security
- WAL mode: Allows concurrent reads without blocking writes; safer for multi-process access.
- Foreign key enforcement:
PRAGMA foreign_keys=ON— all FK constraints are checked at insert/update. - SQLCipher (active): When
db_passphrase(or the launcher's master password) is set, the engine opens the file throughsqlcipher3withPRAGMA keyas the first statement — transparent AES-256 encryption at rest. Empty passphrase = plaintext (backward compatible). Convert/inspect withscripts/db_admin.py(create / encrypt / decrypt). See §19.
Known Limitations & Roadmap Items
| Area | Current State | Path Forward |
|---|---|---|
| LangGraph resume after approval | Column exists; agent-side not wired | Wire langgraph_thread_id → graph resume via SSE stream |
| Field encryption | Columns exist; property wrappers absent | Add @hybrid_property encrypt/decrypt on affected models |
| Authentication | Shared-passcode gate (done) | Optional per-user accounts / OAuth for multi-user setups |
| At-rest encryption | SQLCipher active (done) | Field-level wrappers for SSN/account-number columns still pending |
| Test coverage | Service layer only; API, model, agent tests are stubs | Add TestClient fixtures for API layer; mock LangGraph for agent tests |
| SoftDeleteMixin | Defined but inconsistently applied | Standardize: all models use mixin or explicit is_active |
| CTRA price | Not found by yfinance (possibly delisted) | Manual update from Yahoo Finance; or mark is_publicly_traded=False |
Desktop App · Web · Offline
The app installs and runs on an end user's laptop with no developer tooling, works as an installable offline web app, and stores data encrypted at rest. The full end-user guide is USAGE.md, rendered in-app at /help (reachable from the ? icon in the nav).
Progressive Web App (offline)
- Vendored front-end: Tailwind, HTMX, and Chart.js are served from
static/vendor/— no CDN, so the UI renders with no internet. - Service worker (
/service-worker.js, root scope): precaches the app shell, then serves navigations network-first with a cached fallback and a friendly/offlinepage. Static assets use stale-while-revalidate;/agentand/healthare network-only. - Manifest (
/manifest.webmanifest) + icon make it installable ("Add to Home Screen"). An Offline badge appears in the nav and the app auto-reloads when the connection returns.
Desktop launchers & runtime bootstrap
Per-OS double-click launchers in launchers/ — family-office-macos.command, family-office-linux.sh, family-office-windows.bat. Each installs uv if missing, runs uv sync --frozen (exact versions from uv.lock, wheels-only — no compiler), then runs launchers/_run.py, which prompts for the master password, opens/creates the encrypted DB, picks a free port, and opens the browser.
cryptography is pinned <49: v49 dropped x86_64 macOS wheels (arm64-only), which would force a from-source build on Intel Macs. All native deps (cryptography, numpy, pandas, sqlcipher3-wheels) ship wheels for macOS Intel/ARM, Windows, and Linux.
Master password & encryption
One password (entered at launch) does two jobs via a Settings validator: it always fills db_passphrase (SQLCipher AES-256 at rest — SQLCipher runs its own PBKDF2 over it), and it fills access_code only when bound to 0.0.0.0 (so localhost gets a single prompt, network mode adds the /login gate). First run creates a fresh encrypted database; a wrong password fails the pre-flight with a clear message. See §18.
Settings page (/settings)
- AI key & model: stored in the encrypted DB (
AppSettingtable) and loaded into the live config at startup — no.envediting. Env vars still win if set. - Backup: checkpoints the WAL and downloads a timestamped copy of the encrypted database.
- Import: bulk-load opening positions (see below).
Data ingestion — CSV · JSON · XML
app/services/import_service.py auto-detects the format (extension or content sniff) and imports accounts, securities, and opening tax lots. Shared field names across formats; only account and quantity are required. JSON/XML support flat lists and nested accounts → positions; XML rejects DTD/entity definitions (XXE / billion-laughs guard). Positions are recorded as opening balances (acquisition Transaction + TaxLot), not as trades.
Building a release
scripts/build_release.py assembles dist/family-office-<version>/ + a .zip with the app, launchers, lockfile, INSTALL.md and USAGE.md — and hard-fails if .env, data/, private/, or any *.db is present. Distribution + code-signing/notarization notes live in DISTRIBUTION.md.
Family Office Wiki · v0.1.0 · Updated 2026-06-13 · ← Back to Dashboard