Skip to content

Code Design

Code should be self-documenting. How you split logic into functions and shape data determines how well the codebase holds up.

Semantic Functions

Pure building blocks. All inputs as arguments, all outputs as return values, no hidden side effects. Name describes what it does — no comments needed. Composable: semantic functions can wrap other semantic functions to codify common flows. Break unclear flows into a chain of these.

Function Inputs → Output Why it works
compute_portfolio_context list[Position]PortfolioContext Pure aggregation (value, P&L, weights). Reused by weekly review + daily brief without either worrying about side effects.
watchlist_sources (reads thesis YAML + snapshots) → dict[str, list[str]] Derives union watchlist. Consumers don't know how the union is built.
_compute_risk_snapshot positions, watchlist, portfolio_context, theses → RiskSnapshot Four explicit inputs, one typed output. Concentration, volatility, breach metrics.
_fundamentals YFStockDataAnalysisDimension \| None One of eight dimension scorers composed by _analyze_ticker(). Returns raw_metrics for auditability.

Pragmatic Functions

Orchestration wrappers around semantic functions + unique glue logic. Used in few places. Internals change frequently — callers should not depend on them. Doc comments note only the non-obvious (unexpected preconditions, failure modes). Integration-tested.

WeeklyReviewOrchestrator.run() — 10+ async gathers → compute_portfolio_context_compute_risk_snapshot → build packets → persist. Canonical example: changes as the review pipeline evolves.

DailyBriefOrchestrator.run() — same pattern, pre-market brief, TTL-based caching.

Extraction signal: if a pragmatic function is called from many places, break its reusable logic into semantic functions.

Presenters

Pure transforms: (domain model) → Markdown string. Shared across interface layers.

Pydantic model → presenters.py → Markdown → CLI (echo) / Telegram (html convert) / Web (JSON, separate path)

render_watchlist(sources, report, positions) → str — same Markdown consumed by CLI and Telegram. New consumers add format conversion, not re-implementation.

Rule: if a presenter needs to fetch data, the caller should fetch it and pass it in.

Models

Make wrong states impossible. Models flow through: models/api/schemas/ → OpenAPI → generated TypeScript. One definition per shape — a field change silently affects CLI, API, frontend, and Telegram.

Principles:

  • Minimize optionals. Every optional field is a question every consumer must answer.
  • Name precisely. If you can't tell whether a field belongs by reading the model name, the model is doing too much.
  • Compose, don't merge. Independent concepts needed together: UserWithWorkspace { user, workspace }, not flattened.
  • Use extra="forbid". Catch bad data at construction, not three layers deep.
  • Brand identity types. run_id and batch_id are both strings but mean different things. NewType or thin wrappers prevent silent swaps.

Examples of focused models:

Model Scope Why it's distinct
PortfolioContext Aggregate metrics: value, cost basis, P&L, weights No position details, no market data
ThesisPacketSymbol Per-symbol within a thesis narrative Organized by thesis story
HoldingPacket Per-holding position-level analysis Organized by portfolio position
RiskSnapshot Concentration, volatility, breaches Computed metrics only

Services

Service classes own external credentials and API interactions. Each service conforms to a Protocol defined in services/protocols.py. FinanceApplication accepts injected services and falls back to env-based defaults when none are provided.

Structure:

services/protocols.py     # Protocol definitions (one per service boundary)
services/brokerage.py     # SnapTradeBrokerageService
services/market.py        # FinnhubMarketDataService
services/news.py          # FinnhubNewsService
services/search_service.py# BraveSearchService
services/social.py        # DiscordSocialService
services/macro.py         # FredMacroService
services/sec.py           # EdgarSECService
services/thesis.py        # YamlThesisService (+ models + functions)
services/demo.py          # DemoBrokerageService (fixture data)

Principles:

  • Credentials on the instance. Service constructors resolve credentials from os.environ when explicit values aren't passed. No os.environ reads at call time inside methods.
  • Protocols are structural. Use typing.Protocol — no base classes, no registration. A class conforms by having matching methods.
  • FinanceApplication is the composition root. It accepts brokerage=, market_data=, etc. as keyword-only params defaulting to None. None triggers _default_*() factories that read env. Zero-arg construction still works everywhere.
  • Services that need HTTP accept httpx.AsyncClient as a method param. FinanceApplication owns the client and passes it in. Services stay stateless. Exception: BrokerageService (SnapTrade SDK manages its own client), SocialService (D1 client manages its own HTTP).
  • Snapshot storage stays on FinanceApplication. It wraps every service call with _store_snapshots(). Services don't know about persistence.
  • Demo mode via FinanceApplication.demo(). Returns an instance with DemoBrokerageService. Other services use real defaults (thesis reads local YAML, market data uses env creds if present).

Adding a new service boundary:

  1. Add a Protocol to services/protocols.py
  2. Create a concrete implementation in services/{name}.py
  3. Add a _default_{name}() factory in finance_application.py
  4. Add the kwarg to FinanceApplication.__init__
  5. Delegate the relevant methods to self._{name}

When to extract a new service vs. keep inline:

  • Extract when: the code talks to an external API, needs swappable credentials, or would benefit from a demo/test implementation
  • Keep inline when: it's pure computation, file I/O with no credentials, or called from exactly one place

Where Things Break

Semantic → pragmatic creep. A semantic function gains a side effect for convenience; callers that assumed purity silently break. If compute_portfolio_context() started writing to DB, daily brief, weekly review, and API all get unexpected writes. Fix: name narrow-scope functions by where they're used so the scope is obvious.

Model field accretion. "Just one more" optional field, repeated until the model is a loose bag of half-related data. Four consumers (CLI, API, frontend, Telegram) each guess which fields are set. Fix: when fields stop cohering around the name, split the model.

Presenter impurity. A presenter calls get_latest_composite() — now it's coupled to the DB and untestable without it. Fix: keep presenters as (model) → str, push data gathering to the caller.