mshafir/investbot

Financial planner agent scripts and knowledge

5

stars

35

commits

Python

primary language

Sep 3, 2026

updated

README

investbot

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.

What it will not do

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 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.
  • 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_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.
  • 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.

Quickstart

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:

filewhat it is
.envwhich connector (BROKER, default schwab), its credentials, and the contact address EDGAR requires
portfolio.tomlyour asset classes, targets, symbol mapping, and goals
risk.tomlthe trading limits: allowlist, order caps, spread guard, master switch
orders.tomlthe 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.

The commands

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

How a trade happens

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.

Using it with an agent

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.

Brokers

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:

namewhat it is
schwabthe 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
fakethe 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.

Things that will bite you

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.
  • 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.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.
  • ETFs report as assetType: COLLECTIVE_INVESTMENT. There is no ETF on 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.isOpen means "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 since to what the API will answer; the exports establish history beyond it.

Upstream dependencies to know about

  • 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, 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/.
  • 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 plain httpx against Schwab's REST API. Another connector need not depend on it.

Testing

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.

Skills for an agent

.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.

skillwhen it loadswhat it holds
onboarding-investbota fresh checkout, a new user, or another brokeragethe 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 sessionthe 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-cashnew money to investsee 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-harvestingdrift to correct or losses to harvestfresh 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.

Layout

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

License

MIT. See LICENSE.

Contributors

mshafir

35 commits

mshafir/investbot

Financial planner agent scripts and knowledge

5

stars

35

commits

Python

primary language

Sep 3, 2026

updated

README

investbot

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.

What it will not do

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 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.
  • 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_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.
  • 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.

Quickstart

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:

filewhat it is
.envwhich connector (BROKER, default schwab), its credentials, and the contact address EDGAR requires
portfolio.tomlyour asset classes, targets, symbol mapping, and goals
risk.tomlthe trading limits: allowlist, order caps, spread guard, master switch
orders.tomlthe 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.

The commands

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

How a trade happens

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.

Using it with an agent

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.

Brokers

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:

namewhat it is
schwabthe 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
fakethe 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.

Things that will bite you

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.
  • 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.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.
  • ETFs report as assetType: COLLECTIVE_INVESTMENT. There is no ETF on 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.isOpen means "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 since to what the API will answer; the exports establish history beyond it.

Upstream dependencies to know about

  • 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, 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/.
  • 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 plain httpx against Schwab's REST API. Another connector need not depend on it.

Testing

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.

Skills for an agent

.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.

skillwhen it loadswhat it holds
onboarding-investbota fresh checkout, a new user, or another brokeragethe 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 sessionthe 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-cashnew money to investsee 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-harvestingdrift to correct or losses to harvestfresh 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.

Layout

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

License

MIT. See LICENSE.

Contributors

mshafir

35 commits

Languages

Python

100.0%