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 |
YFStockData → AnalysisDimension \| 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_idandbatch_idare both strings but mean different things.NewTypeor 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.environwhen explicit values aren't passed. Noos.environreads 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 toNone.Nonetriggers_default_*()factories that read env. Zero-arg construction still works everywhere. - Services that need HTTP accept
httpx.AsyncClientas a method param.FinanceApplicationowns 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 withDemoBrokerageService. Other services use real defaults (thesis reads local YAML, market data uses env creds if present).
Adding a new service boundary:
- Add a
Protocoltoservices/protocols.py - Create a concrete implementation in
services/{name}.py - Add a
_default_{name}()factory infinance_application.py - Add the kwarg to
FinanceApplication.__init__ - 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.