Family Office

Private wealth management platform — multi-agent AI, double-entry accounting, tax optimization, and estate planning in one local-first application.

FastAPI 0.115 Claude claude-sonnet-4-5 LangGraph 0.3 SQLite WAL Python 3.12+ v0.1.0 2026
01 🏛️

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

What the system manages

DomainCapability
PortfolioPositions across all accounts; buy/sell execution; per-account cards; 120-day history charts; top movers
TaxTax-loss harvest candidates; YTD realized gain/loss; cost basis election simulation (FIFO vs HIFO vs LIFO); Section 475 analysis; Schedule D generation
AccountingDouble-entry journal with auto-entries for every trade; trial balance; income statement; balance sheet
AnalysisPortfolio 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
EstateGift tracking with annual exclusion / lifetime exemption calculation; GRAT simulation across growth scenarios; ownership tree
Daily BriefMorning / Evening report with 10 macro indices, 11 sector ETFs, and per-position portfolio day impact; narrative auto-generated by the service layer
AI ChatGeneral LangGraph supervisor + direct portfolio advice endpoint with live context injection
02 ⚙️

Tech Stack

CategoryLibraryVersionRole
Web frameworkFastAPI≥0.115ASGI app, routing, dependency injection
ASGI serveruvicorn[standard]≥0.34Production-grade server with hot reload
TemplatingJinja2≥3.1Server-side HTML rendering; 31 templates
ORMSQLAlchemy≥2.0Declarative models; session management
MigrationsAlembic≥1.14Schema versioning (table creation on startup)
ValidationPydantic v2≥2.10Request/response schemas; settings
Agent graphLangGraph≥0.3Multi-agent state machine with message accumulation
Agent supervisorlanggraph-supervisor≥0.0.7Supervisor-worker pattern; routing logic
LLM (primary)langchain-anthropic≥0.3Claude claude-sonnet-4-5 via Anthropic API
LLM (fallback)langchain-openai≥0.3OpenAI fallback if Anthropic key absent
Market datayfinance≥0.2Daily price fetching; bulk download for risk metrics
Data analysispandas / numpy≥2.2 / ≥2.0Return series construction; Monte Carlo simulation
HTTP clienthttpx≥0.28Async HTTP (reserved for future external calls)
Encryptioncryptography≥44.0AES field encryption for SSN/tax ID columns
SSEsse-starlette≥2.0Streaming support (declared, endpoints planned)
Testingpytest / pytest-asyncio≥8.0Service-layer unit tests
Lintingruff≥0.8Fast Python linter
DatabaseSQLite (WAL mode)built-inAll persistent state; WAL for concurrent reads
Frontend CSSTailwind CSSCDNUtility-first responsive layout
Frontend chartsChart.js 4CDNPortfolio history line charts; Monte Carlo fan charts; allocation donut
Frontend helpershtmx 2.0.4CDNPartial 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.

03 📁

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)
04 🔧

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.

SettingEnv VarDefaultNotes
db_pathKFO_DB_PATHdata/family_office.dbRelative to project root
db_passphraseKFO_DB_PASSPHRASE""When set, the database is opened through SQLCipher (AES-256 at rest). Empty = plaintext. See §19.
anthropic_api_keyKFO_ANTHROPIC_API_KEY""Required for AI features
openai_api_keyKFO_OPENAI_API_KEY""Fallback LLM if Anthropic absent
llm_providerKFO_LLM_PROVIDERanthropic
llm_modelKFO_LLM_MODELclaude-sonnet-4-5Override to any Anthropic model ID
market_data_cache_ttl_minutesKFO_MARKET_DATA_CACHE_TTL_MINUTES15Price cache age before re-fetch
trade_approval_thresholdKFO_TRADE_APPROVAL_THRESHOLD50000Dollar amount above which trades need approval
gift_approval_thresholdKFO_GIFT_APPROVAL_THRESHOLD18000Annual exclusion trigger (2026 IRS limit)
hostKFO_HOST127.0.0.1Loopback by default; set 0.0.0.0 to reach the app from other devices (set an access code first). See §19.
portKFO_PORT8000Desktop launcher auto-picks a free port if this is taken
field_encryption_keyKFO_FIELD_ENCRYPTION_KEY""AES key for SSN/account number columns
access_codeKFO_ACCESS_CODE""Empty = open (local-first). Set to require sign-in at /login on every request — used when exposed on a network.
master_passwordKFO_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_daysKFO_SESSION_MAX_AGE_DAYS30Lifetime 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.

05 🗃️

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

family_members ──owns──▶ accounts ◀── family_entities

┌─────────┴──────────────┐
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

ColumnTypeNotes
idInteger PK
first_name, last_nameString(100)Property: full_name
date_of_birthDatenullable
ssn_encryptedString(500)AES-encrypted SSN
relationshipString(50)"principal", "spouse", "child"
emailString(200)nullable
is_activeBooleandefault True

accounts

ColumnTypeNotes
idInteger PK
nameString(200)e.g. "Fidelity 401(k) ...797"
account_typeAccountTypeEnumbrokerage, ira_traditional, ira_roth, 401k, trust, bank_checking, bank_savings, real_estate, private_equity, crypto, hsa, 529, other
institutionString(200)nullable
account_number_encryptedString(500)nullable, AES-encrypted
is_taxableBooleandefault True — controls harvest candidate inclusion
primary_owner_idFK → family_membersnullable
tax_entity_idFK → family_entitiesnullable

assets + asset_prices

ColumnTypeNotes
symbolString(20)nullable (CITs like 14022L645 have no ticker)
nameString(300)Full descriptive name
asset_classAssetClassEnumus_equity, intl_equity, fixed_income, real_estate, private_equity, venture_capital, hedge_fund, commodity, crypto, cash, alternative, other
cusip / isinString(20)nullable — used for CIT identification
is_publicly_tradedBooleanFalse → skip yfinance; use manual price
is_qsbs_eligibleBooleanTracks 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

ColumnTypeNotes
acquisition_dateDateDetermines long-term (≥366 days) vs short-term
original_quantityNumeric(18,8)Immutable — never decremented
remaining_quantityNumeric(18,8)Decremented on sells; 0 triggers is_closed=True
cost_basis_per_unitNumeric(18,6)May be adjusted upward by wash-sale disallowed losses
original_cost_basis_per_unitNumeric(18,6)Immutable — audit reference
is_wash_sale_adjustedBooleanTrue if basis was ever adjusted
wash_sale_disallowed_lossNumeric(18,2)Cumulative disallowed loss added to this lot's basis
is_closedBoolean, indexedFast filter: WHERE is_closed = False

approvals — the HITL gate

ColumnTypeNotes
statusApprovalStatusEnumpending → approved | rejected | expired
action_typeString(100)trade, gift, journal_entry, tax_election
action_payloadText (JSON)Full action detail for exact replay after approval
expires_atDateTimeAuto-set from policy; trades expire in 72h, gifts/elections in 168h
langgraph_thread_idString(200)nullable — stored for future agent-resume capability

Chart of Accounts (27 accounts, seeded on first run)

Code rangeTypeExamples
1010–1900AssetCash (1010), Investments (1200), Real Estate (1400)
2010–2900LiabilityAccounts Payable (2010), Margin Loan (2100)
3010–3900EquityCapital Contributions (3010), Retained Earnings (3500)
4100–4900RevenueRealized Gains (4100), Dividend Income (4200), Interest (4300)
5010–6900ExpenseMgmt Fees (5010), Advisory Fees (5100), Tax Prep (6100)
06

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

MarketDataService

TaxService

AccountingService

ApprovalService

EstateService

ReportingService

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.

07 🔌

API Routes

All routes return HTML (Jinja2-rendered) except /health, /agent/*, /prices/*, and the Monte Carlo JSON endpoint.

Dashboard

MethodPathDescription
GET/KPI cards (AUM, unrealized P&L, YTD, pending approvals), asset allocation bars, top holdings table, quick links

Portfolio /portfolio/*

MethodPathDescription
GET/portfolio/All positions with market values, unrealized P&L, AI Investment Advisor panel
GET/portfolio/accounts-viewPer-account card breakdown with colored header gradient
GET/portfolio/trends120-day portfolio history charts, allocation donut, top gainers/losers, per-account bar chart
GET/portfolio/tradeBuy/sell trade form
POST/portfolio/tradeExecute buy/sell — routes to approval queue if total ≥$50k
GET/portfolio/edit/{asset_id}/{account_id}Edit position quantity/cost basis
POST/portfolio/updateUpdate position
POST/portfolio/delete/{asset_id}/{account_id}Soft-close all lots for position
GET/portfolio/accountsAccount list management
GET/portfolio/assetsAsset list management

Analysis /analysis/*

MethodPathDescription
GET/analysis/morning?refresh=0Daily 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=252Detailed 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=NJSON — N×N Pearson correlation matrix of daily returns for all holdings with price history
GET/analysis/api/returnsJSON — Time-Weighted Return: 1yr, 3yr, 5yr, inception (annualised)
GET/analysis/profilesInvestment profile list — activate, create custom, delete
POST/analysis/profiles/{id}/activateSet active profile (deactivates all others)
GET/analysis/rebalanceDrift 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/*

MethodPathDescription
GET/tax/harvestTax-loss harvest candidates with estimated savings
GET/tax/summary?tax_year=Annual tax summary: gains/losses, income, deductions, estimated liability
GET/tax/electionsCost basis election simulation + Section 475 analysis

Agent /agent/* — JSON endpoints

MethodPathReturnsDescription
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

MethodPathDescription
POST/prices/refreshBulk yfinance price fetch for all public, non-stable-value assets
GET/prices/statusPrice freshness: total public assets, updated today count, latest price date
08 🖥️

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

Key Pages

TemplateRouteNotable 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-viewPer-account gradient cards with P&L coloring; top-3 holdings per account
portfolio/trends.html/portfolio/trendsLine chart (total + by-account toggle); allocation donut; gainers/losers with bar indicators; account bar chart
analysis/morning.html/analysis/morningSession badge (AM/PM), macro indices grid, sector heatmap sorted by performance, portfolio day-impact table, narrative paragraph
analysis/monte_carlo.html/analysis/monte-carloFan chart with P10–P90 bands for current + target allocation; goal line overlay; probability of reaching goal
analysis/risk.html/analysis/riskPortfolio-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/gratMulti-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.

09 🔒

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).

10 🤖

AI Agent System

Architecture — Two Paths

The AI system has two distinct invocation paths:

  1. 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.
  2. 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

POST /agent/invoke

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:

Specialist Agents & Tools

AgentToolsHas Approval Tools?
portfolio_agentget_portfolio_summary, get_market_quote, get_open_tax_lots, execute_buy_trade, execute_sell_tradeYes
tax_agentfind_harvest_candidates, get_tax_summary, simulate_tax_electionYes
accounting_agentget_trial_balance, get_income_statement, get_balance_sheet, get_journal_entriesNo
reporting_agentgenerate_quarterly_report, generate_schedule_d, check_compliance, get_compliance_flagsNo
estate_agentget_ownership_tree, get_gift_history, simulate_grat, get_annual_exclusion_remaining, get_lifetime_exemption_remainingYes
analysis_agentget_risk_metrics, get_allocation_drift, list_investment_profiles, set_investment_profile, run_monte_carlo_simulationNo

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.

11 📰

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 KeyReport TitleNext Update
Midnight – 4:05 PMYYYY-MM-DD-AMMorning Brief"at market close (4:05 PM ET)"
4:05 PM – MidnightYYYY-MM-DD-PMEvening Report"at market open tomorrow"
Saturday / SundayYYYY-MM-DD-AMWeekend Summary"at market open Monday"

Data Fetched per Session

CategorySymbols
Macro indicesSPY, QQQ, DIA, IWM, ^VIX, TLT, GLD, USO, UUP, BTC-USD
Sector ETFsXLK, XLV, XLF, XLE, XLI, XLY, XLP, XLU, XLB, XLRE, XLC
Portfolio positionsAll 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:

  1. S&P direction and magnitude
  2. VIX level (elevated/low)
  3. Bond/equity relationship (TLT direction vs SPY)
  4. Top sector leader and laggard
  5. Gold / Bitcoin moves if notable (>1%)
  6. 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).

RegimeLabel 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.

12 📈

Risk & Portfolio Analysis

Risk Metrics (AnalysisService)

MetricCalculationNotes
Portfolio Volatilityportfolio_std × √252Annualized, 252-day basis
Sharpe Ratio(annualized_return − risk_free) / volatilityDefault risk-free = 5%
Max Drawdownmin((V_t − peak_t) / peak_t)Returns peak date, trough date
Portfolio Betacov(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 Betacov(pos_returns, SPY) / var(SPY)
Risk Contribution %w_i × Cov(r_i, r_p)_annual / Var(r_p)_annual × 100Exact Euler decomposition — contributions sum to 100%
1-Day VaR (95%)|5th-percentile daily return| × portfolio_AUMHistorical simulation, no normality assumption
CVaR / ES (95%)|mean(returns below VaR)| × portfolio_AUMExpected Shortfall — captures tail severity beyond VaR
VaR / CVaR (99%)Same as above at 1st percentileWorst-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)

ClassAnnual ReturnVolatility
us_equity10%16%
intl_equity8%18%
fixed_income4%5%
real_estate7%12%
private_equity12%22%
venture_capital15%30%
crypto15%60%
cash4%0.5%
commodity5%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%)
Sideways0×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

FieldDescription
final_median / p10 / p90End-of-accumulation portfolio values
probability_of_goal% of simulations ending ≥ goal_amount
regime_context"Bull" | "Sideways" | "Bear" | "Base"
monthly_contributionMonthly deposit amount used in simulation
withdrawal_annual_amountMedian annual withdrawal (= median final_val × rate)
withdrawal_totalMedian total withdrawn over distribution years
withdrawal_final_median / p10 / p90Portfolio 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.

ProfileUS EquityIntl EquityFixed IncomeCash
Conservative20%10%55%15%
Moderate35%15%40%10%
Balanced (default active)45%20%25%5%
Growth60%25%10%5%
Aggressive70%20%5%5%
13 💰

Tax & Compliance

Tax Rates (2026 approximate)

Rate TypeValueApplies to
Short-term capital gains37%ST gains + interest income (ordinary rate, top bracket)
Long-term capital gains20%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

  1. Query all open TaxLot records in taxable accounts (is_taxable=True)
  2. Aggregate by (asset, account) — sum remaining quantities and cost basis per unit
  3. Fetch current price; compute unrealized loss = (current_price − cpu) × qty
  4. Flag wash-sale risk: check for any purchase of same asset within 30 days of today
  5. Estimate tax savings: |loss| × (rate + NIIT) where rate = 37% if short-term, 20% if long-term
  6. Return HarvestCandidate list sorted by loss magnitude (most negative first)

Wash-Sale Detection (at sell time)

execute_sell()
↓ 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

MethodDescriptionRecommended?
FIFOOldest lots sold first — typically lowest remaining cost basisNo
HIFOHighest cost basis lots sold first — minimizes realized gainYes
LIFOMost recently acquired lots sold firstNo
Section 475Mark-to-market trader status — all gains/losses ordinaryInformational only
14 🏛️

Estate Planning

Gift Tracking

GRAT Simulation

A Grantor Retained Annuity Trust lets the grantor transfer appreciation above the Section 7520 hurdle rate to beneficiaries tax-free. The simulation:

  1. Compute annuity payment: funding × (rate / (1 − (1 + rate)^(−term)))
  2. 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)
  3. Tax savings estimate: remainder × 40% (estate tax rate)
  4. 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.

15

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 NameTrigger TypeThresholdExpiry
Large Tradetrade_amount$50,00072 hours
All GiftsgiftNone (always)168 hours (7 days)
Tax Electiontax_electionNone (always)168 hours
Manual Journaljournal_manual$10,00072 hours

Approval Lifecycle

Material action requested (trade, gift, election, journal)

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.

16 🔄

Data Flows

Trade Execution Flow

User submits trade form POST /portfolio/trade

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

scripts/update_prices.py (run daily)

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

  1. Query all TaxLot WHERE is_closed = False (fast index scan)
  2. Group by (asset_id, account_id) → aggregate remaining_quantity, weighted average CPU
  3. For each group: fetch current price → compute market_value, unrealized_gain_loss, weight_pct
  4. Query all TaxLotDisposal for current year WHERE disposal_date ≥ Jan 1 → sum realized YTD
  5. Aggregate market values by asset_class → allocation percentages
  6. Return PortfolioSummary Pydantic object

CSV Import Flow (scripts)

Schwab / Fidelity exports CSV (downloaded from brokerage UI)

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
17 🛠️

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:

ScriptAccountSource Format
update_schwab_000.pySchwab ...000Schwab CSV position export
update_schwab_645.pySchwab ...645 (Contributory)Schwab CSV position export
update_schwab_912.pySchwab ...912Schwab CSV position export
update_fidelity.pyFidelity accountsFidelity CSV + screenshot data
update_529.py529 plan529 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
18 🔐

Security & Data Model Notes

Network Security

Field-Level Encryption (Partial Implementation)

ColumnModelStatus
ssn_encryptedfamily_membersColumn defined, AES key in config, encrypt/decrypt wrappers not yet implemented
tax_id_encryptedfamily_entities, vendorsSame — structural scaffold in place
account_number_encryptedaccountsSame

Database Security

Known Limitations & Roadmap Items

AreaCurrent StatePath Forward
LangGraph resume after approvalColumn exists; agent-side not wiredWire langgraph_thread_id → graph resume via SSE stream
Field encryptionColumns exist; property wrappers absentAdd @hybrid_property encrypt/decrypt on affected models
AuthenticationShared-passcode gate (done)Optional per-user accounts / OAuth for multi-user setups
At-rest encryptionSQLCipher active (done)Field-level wrappers for SSN/account-number columns still pending
Test coverageService layer only; API, model, agent tests are stubsAdd TestClient fixtures for API layer; mock LangGraph for agent tests
SoftDeleteMixinDefined but inconsistently appliedStandardize: all models use mixin or explicit is_active
CTRA priceNot found by yfinance (possibly delisted)Manual update from Yahoo Finance; or mark is_publicly_traded=False
19 💻

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)

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)

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