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.
investbot execute shows what the rebalancing
strategy would do and cannot place anything. Only investbot place
can reach a broker's order endpoint, and it reads a file you wrote.MARKET.place_order attempts 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.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 place
init 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 stale
Edit 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:
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.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.download defaults to period="1mo". Always pass
period="max" or you silently backtest on ~24 rows.quote.quoteTime is 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.assetType: COLLECTIVE_INVESTMENT. There is no
ETF on the wire.markets.isOpen means "today is a trading day", not "open right
now."since to what the API
will answer; the exports establish history beyond it.lookthrough, overlap and sector-exposure commands)
requires every request to carry the requester's real contact address in
its User-Agent, per SEC policy. Set INVESTBOT_EDGAR_CONTACT in
.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/.brokers/schwab/auth.py, and a
test keeps it there; the rest of the connector is plain httpx against
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 account
The 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.
35 commits
Python
100.0%
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.
investbot execute shows what the rebalancing
strategy would do and cannot place anything. Only investbot place
can reach a broker's order endpoint, and it reads a file you wrote.MARKET.place_order attempts 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.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 place
init 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 stale
Edit 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:
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.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.download defaults to period="1mo". Always pass
period="max" or you silently backtest on ~24 rows.quote.quoteTime is 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.assetType: COLLECTIVE_INVESTMENT. There is no
ETF on the wire.markets.isOpen means "today is a trading day", not "open right
now."since to what the API
will answer; the exports establish history beyond it.lookthrough, overlap and sector-exposure commands)
requires every request to carry the requester's real contact address in
its User-Agent, per SEC policy. Set INVESTBOT_EDGAR_CONTACT in
.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/.brokers/schwab/auth.py, and a
test keeps it there; the rest of the connector is plain httpx against
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 account
The 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.
35 commits
Python
100.0%