Skip to content

Architecture

Cents is intentionally boring on the storage layer: SQLite, repository pattern, no daemon. The interesting parts are (1) the agent orchestration, (2) the regime-aware substrate that ties events to theses, (3) the autonomous factory loop on top, and (4) the experiments registry that pre-registers hypotheses against a frozen factory.toml SHA so analytics stay falsifiable.

The factory engine records what happens — it does not gate on trading-style controls. Drawdown, liquidity, borrow, beta-matched hedging, and calibrated-p are computed on every thesis but never block an open. cents/finance/ is a transversal utility library for callers writing their own analytics, not a set of gates the engine relies on. See Scope.

CLI commands hit repositories, which talk to a single SQLite database. External data providers are called from the agents only — the persistence layer never reaches out to the network.

flowchart LR
CLI[CLI commands<br/>thesis · research · scan · event · factory · cohort · universe · usage]
Repo[Repositories<br/>ThesisRepository · EvidenceRepository · EventRepository<br/>PositionRepository · WatchlistRepository · LLMUsageRepository<br/>UniverseRepository · FactoryRunRepository · AlertRepository]
DB[(SQLite<br/>~/.cents/data/cents.db)]
Agents[Research agents<br/>incl. EventAgent]
Factory[Factory engine<br/>autonomous loop]
FMP[(FMP)]
Alpaca[(Alpaca)]
FRED[(FRED)]
News[(NewsAPI)]
FedReg[(Federal Register)]
Anthropic[(Anthropic)]
CLI --> Repo
Repo <--> DB
CLI --> Agents
CLI --> Factory
Factory --> Agents
Factory --> Repo
Agents --> FMP
Agents --> Alpaca
Agents --> FRED
Agents --> News
Agents --> FedReg
Agents --> Anthropic
Agents --> Repo

The repository pattern accepts an optional conn so tests can inject an in-memory SQLite connection — see tests/conftest.py for the fixtures.

Every Anthropic call routes through record_llm_usage() which persists a row to the llm_usage table, so cents usage summary can break spend down by agent / model / day / operation.

The orchestrator runs every child agent, collects their AgentResults, and folds them into a single weighted synthesis.

flowchart TB
Symbol[symbol + thesis]
F[Fundamentals<br/>FMP]
T[Technical<br/>Alpaca]
M[Macro<br/>FRED]
S[Sentiment<br/>NewsAPI + Anthropic]
Mo[Moat<br/>FMP]
I[Insider<br/>FMP]
E[Event<br/>Federal Register + Anthropic]
Orch[Orchestrator<br/>weighted aggregate]
Result[AgentResult<br/>evidence · conviction_delta · dimension_scores]
Symbol --> F
Symbol --> T
Symbol --> M
Symbol --> S
Symbol --> Mo
Symbol --> I
Symbol --> E
F -- AgentResult --> Orch
T -- AgentResult --> Orch
M -- AgentResult --> Orch
S -- AgentResult --> Orch
Mo -- AgentResult --> Orch
I -- AgentResult --> Orch
E -- AgentResult --> Orch
Orch --> Result

The orchestrator’s weighting combines two factors:

  • Confidence weight — each evidence item carries a confidence in [0, 1]; the agent’s mean evidence confidence scales its conviction delta.
  • Age decay — evidence weight decays linearly from 1.0 toward a 0.1 floor over a per-dimension TTL (7 days for technical/sentiment, 30 days for macro/valuation/risk, 90 days for quality/moat).

A per-agent clamp of ±10 conviction points keeps any single agent from dominating the result. The orchestrator’s own aggregate result uses a higher cap of ±30, so a strong-consensus signal isn’t quantized to ±10 by the per-agent clamp.

cents factory run --orchestrator random swaps the multi-agent orchestrator above for RandomOrchestrator (cents/agents/random_orchestrator.py), which emits a uniform-random conviction_delta in [-30, +30] with no LLM calls. Theses opened by this orchestrator are stamped with orchestrator_label = "random" so cohort analytics can compare the LLM arm against a matched-cadence baseline run on the same universe. Without this, no cohort spread the LLM arm produces can be attributed to the LLM signal versus the act of opening theses in whatever tape happened to be running. The seed parameter makes runs reproducible.

cents experiment register <spec.yaml> writes an Experiment row that freezes the current factory.toml SHA + body, plus a hypothesis, primary metric, and minimum_n_per_arm target. While the experiment is active, the factory engine stamps experiment_id on every opened thesis. cents experiment status tracks progress against the target; cents experiment finalize <name> locks the run with an optional verdict JSON. This is the surface that makes the analytics falsifiable rather than post-hoc storytelling — see cents experiment for the workflow.

Three primitives turn cents from a research tool into a regime-aware research tool:

flowchart LR
EvA[EventAgent.refresh]
FedReg[(Federal Register)]
EvT[(events table)]
Theses[(theses table<br/>premise_tags · regime_snapshot)]
Alert[PREMISE_INVALIDATION<br/>alert]
Factory[Factory close phase]
EvA -->|pulls daily| FedReg
EvA -->|LLM-tags against<br/>EVENT_TAGS vocab| EvT
EvT -->|cross-reference on ingest<br/>tag intersection| Theses
Theses -->|matched thesis| Alert
Alert -->|read in close phase| Factory
Factory -->|outcome=INVALIDATED| Theses
  • Thesis.premise_tags — the regime variables a thesis depends on (e.g. ["tariffs.china", "ai_capex"]), drawn from the controlled vocabulary EVENT_TAGS.
  • Thesis.regime_snapshot — the regime context at thesis birth: recent event count, top tag counts, net polarity. Stored as JSON for later cohort-stratified analytics.
  • AlertType.PREMISE_INVALIDATION — fires when an ingested event’s tags intersect an open thesis’s premise_tags. Carries data.thesis_id and data.matched_tags. The factory’s close phase reads these and closes matching theses as INVALIDATED.

See Events & premise invalidation for the full mechanics.

The factory walks a universe of symbols. Where does that universe come from? In v1 it can be supplied four ways — and the most interesting one is a screener, which makes discovery a measurable, swappable component instead of a hard-coded list:

flowchart LR
S1[Screener<br/>value / growth / momentum / ...]
S2[Index<br/>sp500 / nasdaq / ...]
S3[Watchlist<br/>manual]
S4[Static list<br/>your basket]
U[(Universe)]
Factory[cents factory run]
T[(Theses<br/>discovery_source = universe.name)]
S1 --> U
S2 --> U
S3 --> U
S4 --> U
U --> Factory
Factory --> T
T -.-> Analyze[factory analyze --by discovery,regime,cohort]
Analyze -.->|outcomes ranked by<br/>discovery strategy| S1

The dashed feedback loop is what makes the discovery layer learnable. Every thesis records the universe (and hence the screener) that produced its symbol; cents factory analyze --by discovery later stratifies outcomes by that source. After enough closes, the system can tell you which discovery strategies actually produce winning theses in which regimes.

The factory walks a universe of symbols, opens paper theses where the orchestrator clears the entry threshold (paired with sector-ETF twins in paired mode), closes positions on target / stop / horizon / premise-invalidation, and writes a structured run log.

flowchart TB
Run[cents factory run]
RefreshEv[refresh events<br/>EventAgent.refresh]
Close{close phase}
PremiseAlert{PREMISE_INVALIDATION<br/>alert?}
TargetStop{target / stop / horizon?}
CloseTh[close thesis<br/>record outcome reason]
Universe[(universe symbols)]
Open{open phase}
Orchestrator[run orchestrator]
Threshold{|delta| ≥ threshold?}
Premise[classify premise tags<br/>LLM call]
Concentration{premise tag at cap?}
Budget{budget OK or preempt?}
OpenTh[create thesis<br/>open positions]
Record[record run<br/>llm cost, counts]
Run --> RefreshEv
RefreshEv --> Close
Close -->|for each open thesis| PremiseAlert
PremiseAlert -->|yes| CloseTh
PremiseAlert -->|no| TargetStop
TargetStop -->|yes| CloseTh
TargetStop -->|no| Open
Close --> Open
Open -->|for each symbol| Orchestrator
Orchestrator --> Threshold
Threshold -->|no| Open
Threshold -->|yes| Premise
Premise --> Concentration
Concentration -->|hit| Open
Concentration -->|ok| Budget
Budget -->|no fit, no preempt| Open
Budget -->|fits or preempts lower-conviction| OpenTh
OpenTh --> Open
Open --> Record

Key contracts:

  • The close phase respects a same-run cooldown: a symbol closed as INVALIDATED won’t be re-opened in the same run (its hedge symbol too).
  • Direction follows signal sign: bullish delta opens LONG underlying, bearish opens SHORT underlying; hedges (in paired mode) sit opposite. Target / stop semantics flip accordingly.
  • Preemption is conviction-weighted: a new thesis can displace the lowest-conviction open thesis only if new_conviction > lowest_open.conviction + preemption_margin. Displaced thesis closes as PREEMPTED (stratified out of win-rate analytics).
  • LLM cost on factory_runs.llm_cost_usd is computed by diffing llm_usage rows where called_at falls in [started_at, completed_at], priced via cents.pricing.estimate_cost_usd.

See Factory for the operating rules and budget mechanics in detail.

cents/finance/ is a transversal utility library. Each module exposes primitives the factory could gate on but deliberately doesn’t in the default research configuration. They exist so analytics callers can stratify outcomes by these dimensions, and so users who want trading- shaped behaviour can opt in explicitly.

ModuleWhat it providesOpt-in via
sizing.pyvol_scaled_shares — inverse-vol sizing toward a target $-vol fraction.sizing_mode = "vol_scaled" (default: "equal_dollar")
costs.pyapply_open_cost / apply_close_cost — commission + slippage + borrow + gap penalty.Always on — research honesty, applied so Position.pnl is net of costs. Cohort analytics should always use pnl, not gross_pnl.
hedging.pyestimate_beta + beta_match_ratio — 60-day OLS beta vs hedge ETF, refuses estimation when R² < threshold.beta_match_hedge = true (default: false, dollar-matched)
liquidity.pypasses_liquidity_gate / passes_borrow_gate — utilities only; the engine never skips on them.Analytics callers — never used as a gate in the default engine.
portfolio.pycompute_drawdown + check_kill_switch — utilities only; the engine never halts on them.Analytics callers — never used as a gate in the default engine.
calibration.pyCalibrationModel + fit_calibration — logistic regression on outcomes.Recorded on every thesis as calibrated_p_correct; the engine never skips on it.

The split between “recorded” and “gated” is deliberate. The point of the research pipeline is to study what happens at every conviction level / drawdown / liquidity tier, not to filter the dataset to the tier that looks best ex-ante.

Every agent — including the orchestrator and EventAgent — returns the same dataclass. This is the surface the HTML export, the JSON serializer, and the CLI all consume.

classDiagram
class AgentResult {
+list~Evidence~ evidence
+float conviction_delta
+str summary
+dict~str, float~ dimension_scores
+dict metadata
+bool aggregate
+__post_init__()
}
class Evidence {
+str symbol
+EvidenceType type
+str description
+float confidence
+ThesisDimension dimension
+datetime timestamp
+str source
}
AgentResult "1" --> "*" Evidence : contains
  • evidence — every supporting / contradicting / neutral observation the agent generated, including a numeric confidence and the dimension it speaks to.
  • conviction_delta — clamped in __post_init__. The cap is ±10 (MAX_CONVICTION_DELTA) for individual agents and ±30 (MAX_AGGREGATE_CONVICTION_DELTA) when aggregate=True (the orchestrator).
  • dimension_scores — per-dimension contributions (valuation, quality, moat, technical, risk, macro, sentiment).
  • summary — human-readable string surfaced in CLI output.
  • metadata — escape hatch for agent-specific extras (signal mode flags, raw provider responses for debugging).
Not financial advice. Cents is an educational and research tool for tracking your own investment theses. Outputs are model-generated and may be inaccurate. You are solely responsible for your own investment decisions.