An agent that advises on, and executes against, a brokerage portfolio. It inspects positions, measures drift from a target allocation, finds tax-loss harvests, sees through funds to issuer-level exposure, backtests rebalancing policies on dividend-adjusted history, and places orders -- but only orders you wrote down and read back first.
One brokerage per installation, behind one interface. Everything that
knows a broker's wire format lives in a connector package under
src/investbot/brokers/; the strategies, the ledger, the tax logic and
the execution pipeline talk to Broker and never to a connector. Charles
Schwab is the reference connector and the one this was built against.
Another brokerage is a connector package, not a rewrite; see
Brokers below and docs/CONNECTORS.md.
Not investment advice. It is a tool that does arithmetic on your account and puts the result in front of you. Every trade it places is one you typed into a file. Read docs/SAFETY.md before you arm it.
Read these first; they are the limits that shape everything else.
- No unattended operation. A Schwab refresh token lasts exactly 7 days and renewing one needs an interactive login; the connector checks it before every call and stops rather than half-completing. Nothing here can run for a week without you.
- No paper trading. Schwab does not offer it, and the pipeline does not fake one. The first real order this system places is the only untested line in it; the broker's preview call validates everything up to that line.
- No bulk execution.
investbot executeshows what the rebalancing strategy would do and cannot place anything. Onlyinvestbot placecan reach a broker's order endpoint, and it reads a file you wrote. - No market orders. Every order is a limit order, priced at the touch
and spread-guarded. There is no code path that sends
MARKET. - No retried order. Every connector's
place_orderattempts exactly once. A timeout or an unrecognised response aborts the whole run rather than trying again, because a retried POST at a broker without an idempotency key is a double execution. - No cost-basis estimation. Tax-loss harvest candidates appear only when the reconstructed lot count reconciles exactly against the broker's live count. Anything less is reported as a gap, never guessed.
- No aggregation across brokerages. One connector is selected per installation. Combining accounts at two brokers is a different project.
You need Python 3.12, uv, and credentials for a brokerage that has a connector. Out of the box that means Schwab: a developer app with the Accounts and Trading entitlement, plus Market Data (quotes), which is approved separately and is needed to place orders. For another brokerage, see Brokers first.
git clone <this repo> && cd investbot
uv sync
uv run investbot init # copies the example configs into placeinit creates four gitignored files from their committed examples:
| file | what it is |
|---|---|
.env |
which connector (BROKER, default schwab), its credentials, and the contact address EDGAR requires |
portfolio.toml |
your asset classes, targets, symbol mapping, and goals |
risk.toml |
the trading limits: allowlist, order caps, spread guard, master switch |
orders.toml |
the orders to place -- empty until you write some |
Then:
uv run investbot auth # the connector's login; Schwab's token lasts 7 days
uv run investbot positions # confirm it can see your accounts
uv run investbot drift # how far each class is from target
uv run investbot doctor # portfolio.toml and risk.toml agree; nothing is staleEdit portfolio.toml to describe what you actually intend. The example
targets are illustrative and the goals are examples of the shape. Run
doctor after every edit to either file; it exits non-zero on a
cross-file mistake, so it can also run from cron.
Inspect -- read-only, no entitlements beyond Accounts:
positions balances allocation drift expenses quotes
sector-exposure lookthrough overlap substitutes
Tax -- reads a local ledger you populate from your broker's CSV exports (the importers are Schwab's; see Brokers):
import-transactions import-positions import-lots import-realized
sync lots harvest gains ledger-export
The ledger is the only persistent state. Tax lots cannot be re-derived
from the API, so data/ holds a SQLite file and is gitignored. Run
investbot sync before trusting any tax figure.
Plan and trade:
goals the soft rules, printed for whoever is translating a plan
plan sequence harvest, rebalance and deployment into one safe plan
execute what the rebalancer would do. Reporting only; cannot place.
place place the orders in orders.toml. Dry-run unless --live.
orders recent orders as the broker records them
Backtest:
backtest compare rebalancing triggers over dividend-adjusted history
investbot plan --> you read it, and the goals --> orders.toml --> investbot place --live
(mechanical) (judgment) (written down) (gated, one POST each)
The middle step is deliberate and stays manual. plan is a referee: it
sequences harvesting, rebalancing and cash deployment so they do not
undercut each other -- the rebalancer will otherwise buy back a symbol the
harvester just sold at a loss. But plan has no notion of "that trim
realises a gain to fix noise" or "new international money should go to the
fund we are growing, not the one we are winding down." Those are goals,
declared in portfolio.toml, and applying them is the job of whoever
turns the plan into orders.toml. See docs/JUDGMENT.md.
Then place runs every order through the gate described in
docs/SAFETY.md and, if armed, hands each one to the
broker exactly once.
The repo ships an MCP server (investbot.mcp_server) exposing the
read-only surface -- positions, drift, harvest candidates, look-through,
backtests. It deliberately does not expose place, and a test
asserts it never passes live=True. An agent can inspect, plan, and draft
orders.toml; a person runs the armed command. CLAUDE.md is written for
that agent and is the densest description of the invariants in the repo.
investbot.brokers.protocol.Broker is nine methods: credential status,
accounts, account references, quotes, market hours, orders,
transactions, preview an order, place an order. Everything above it --
execute.py, the CLI, the MCP server, the ledger, the planner -- imports
the protocol and the neutral models it speaks in, and a boundary test
fails if any of them names a connector. BROKER=<name> in .env selects
the connector; investbot auth runs whichever login that connector
defines.
Two connectors ship:
| name | what it is |
|---|---|
schwab |
the reference connector, and the default. Everything that knows Schwab's wire format lives under brokers/schwab/: OAuth, the retrying GET transport, the parsers, the one POST, and the CSV export importers |
fake |
the in-memory Broker every test runs against. A test double, never a stand-in for real data |
Writing a third is a package under brokers/ with the nine methods, a
from_settings and an authorize classmethod, and two registry lines.
docs/CONNECTORS.md is the contract. The part a
contributor can get wrong in a way that costs money is place_order,
so it is enforced three ways: a boundary test scans every connector's
order methods for a loop or a call to the retrying transport, a
conformance suite counts attempts against every registered connector,
and a connector cannot be registered without a conformance entry. The
gate, the audit log, the redaction and the abort-on-ambiguity all live
above the seam, where a connector cannot route around them.
What the seam deliberately does not abstract:
- The asset-type vocabulary is Schwab's.
EQUITY,COLLECTIVE_INVESTMENT(ETFs),FIXED_INCOME,MUTUAL_FUND,OPTION. A connector maps its broker's types onto these; a neutral enum would be a mapping table nobody has data for. - The CSV export importers are Schwab's. No live API at any broker exposes lot-level cost basis, so the tax ledger is seeded from files the broker's website exports, and those are per-broker formats. A second connector brings its own importers; nothing generic consumes them.
- One broker per installation. Selected by config, not aggregated.
Learned against the live APIs. Do not re-derive these. The first two are general; the rest are Schwab's, and each is the kind of thing a new connector will have its own version of.
yf.downloaddefaults toperiod="1mo". Always passperiod="max"or you silently backtest on ~24 rows.- Money market funds return 0.0% in every price source. Their return is distributions. They are modelled as cash accruing a declared yield.
- Schwab price history is split-adjusted but not dividend-adjusted. Use yfinance for total return.
quote.quoteTimeis epoch milliseconds, and it records when the quote last changed, not when the market was last heard from. A thin fund reads as stale for being quiet.- ETFs report as
assetType: COLLECTIVE_INVESTMENT. There is noETFon the wire. - The API's fixed-income cost basis is unusable -- quantity in $1,000 units, price per $100 face. The real basis comes from the positions export.
markets.isOpenmeans "today is a trading day", not "open right now."- Orders and transactions return 400 without an explicit date range,
capped at 60 days back. The connector clamps
sinceto what the API will answer; the exports establish history beyond it.
- yfinance is an unofficial scraper of Yahoo Finance. It breaks when Yahoo changes something and is fixed when someone notices. Backtests and fund metadata depend on it; live trading does not.
- EDGAR (the
lookthrough,overlapandsector-exposurecommands) requires every request to carry the requester's real contact address in itsUser-Agent, per SEC policy. SetINVESTBOT_EDGAR_CONTACTin.env; the tool refuses to call EDGAR without it rather than sending a placeholder. Rate limit is 10 requests/second. Responses are cached under.cache/. - schwab-py handles the Schwab connector's OAuth and nothing else.
It is imported in exactly one module,
brokers/schwab/auth.py, and a test keeps it there; the rest of the connector is plainhttpxagainst Schwab's REST API. Another connector need not depend on it.
uv run pytest # offline; never touches the network
uv run pytest -m contract # live read-only calls; needs a real accountThe default suite refuses real HTTP everywhere via an autouse fixture, not
just by deselecting the contract tests. Fixtures under tests/fixtures/
are anonymised derivatives of real payloads; scripts/anonymize_fixtures.py
produces them and tests/test_fixture_anonymization.py fails if any real
symbol, CUSIP, share count or dollar amount survives.
Tests marked local_only read your private portfolio.toml and
risk.toml pair and skip on a checkout that has none.
.claude/skills/ holds four Claude Code skills, one per way a session
with this repo tends to start. Each is a fixed sequence and a fixed output
shape, so what changed stands out and what must not happen does not.
| skill | when it loads | what it holds |
|---|---|---|
onboarding-investbot |
a fresh checkout, a new user, or another brokerage | the interview: broker (Schwab, build a connector, or offline only), intent and priorities into portfolio.toml and [[goals]], tax facts into CLAUDE.local.md, authorisation into risk.toml, ledger seeding, doctor |
briefing-portfolio |
"where do we stand", "what next", the start of any session | the read in a fixed order, the standing notes checked against it, the plan read through the goals, next steps ordered with data freshness first |
deploying-cash |
new money to invest | see the cash before sizing (the gate does not check it), size to class deficits through the goals, orders.toml with declined trades as comments, dry run, the execution gate |
rebalancing-and-harvesting |
drift to correct or losses to harvest | fresh export mandatory, the harvest and rebalance judgment calls, quarantine bookkeeping, the same execution gate |
The execution gate is the same in both trading skills and is stated in
CLAUDE.md: an agent drafts and dry-runs; placing needs the user to
confirm that dry run in words after seeing it, first order alone, then the
rest. A go-ahead given before the dry run existed does not count.
tests/test_skills.py checks each skill's frontmatter and that both
trading skills carry the gate.
src/investbot/
brokers/
__init__.py REGISTRY and AUTH_FLOWS; load_broker picks one by BROKER
protocol.py the Broker interface and the neutral models it speaks in
http.py placed / rejected / ambiguous, from an HTTP status
fake.py the in-memory Broker every test runs against
schwab/ the reference connector: auth.py is the only module that
imports schwab-py; transport.py retries GETs and nothing
else; parse.py owns the wire format; orders.py sends the
one POST; exports.py reads Schwab's CSV exports
execute.py the pipeline between a proposal and a Broker. Every stage
may only refuse; an ambiguous outcome aborts the run.
orders.py ProposedOrder -> PlaceableOrder. Whole shares, LIMIT only.
market.py quote-age and market-hours gates over the neutral models
reconcile.py same-day duplicate check against the broker's order list
orderfile.py orders.toml parser. Refuses anything ambiguous.
risk.py the gate every order passes through
policy.py portfolio.toml: classes, targets, goals
rebalance.py pure strategy; no I/O, no clock
plan.py the referee between harvest, rebalance, deploy
tlh.py tax-loss harvest candidates and sizing
washsale.py the 61-day window, proportional disallowance
ledger.py the only module that imports sqlite3
sync.py reconcile the ledger against the API
lookthrough.py issuer-level exposure through funds
edgar.py the only module that touches sec.gov
yahoo.py the only module that imports yfinance
mcp_server.py read-only tools for an agent
cli/ everything a person types, one module per subsystem
MIT. See LICENSE.