CashAsh

Commands

Always use uv run. Run any command with --help for the full flag list.

uv run python main.py profile add NAME --telegram-id ID --operator   # first profile
uv run python main.py profile list     # the roster
uv run python main.py init             # create and seed from the snapshot file
uv run python main.py sync             # prices, FX, earnings dates, consensus
uv run python main.py events           # known upcoming dates for your holdings
uv run python main.py thesis           # why each position is held
uv run python main.py triage           # rank holdings by what changed
uv run python main.py research         # deep pass on what triage selected
uv run python main.py recommend        # propose actions, rules applied
uv run python main.py decide           # list and record your decisions
uv run python main.py weekly           # the whole cycle, in one command
uv run python main.py report           # the weekly review
uv run python main.py benchmark        # against the same money in a tracker
uv run python main.py eval             # is the machinery sound?
uv run python main.py telegram-setup   # register the bot command menu
uv run python main.py daemon           # listen for button presses
uv run python main.py daemon install   # run the listener under launchd
uv run python main.py models           # which model each stage calls
uv run python main.py llm-log          # recorded model calls and their cost
uv run python main.py price TICKER --close AMOUNT   # record a price by hand
uv run python main.py holdings         # positions, cost basis, value, P&L
uv run python main.py concentration    # grouped weights and limit breaches
uv run python main.py context          # personal context files and their status
uv run python main.py doctor           # what is set up and what is missing
uv run python main.py db status        # row counts and migration state
uv run python main.py db migrate       # apply pending migrations
uv run python main.py db schema        # print the live schema

Useful examples:

uv run python main.py init --dry-run          # verify the snapshot reconciles, write nothing
uv run python main.py init --snapshot ./other.toml   # seed from a different file
uv run python main.py concentration --by theme
uv run python main.py concentration --by sector
uv run python main.py holdings --verbose      # debug logging on stderr
uv run python main.py sync --provider yfinance
uv run python main.py price SPCX --close 147.95 --date 2026-09-04
uv run python main.py sync --prices-only      # skip the calendar fetch
uv run python main.py events --days 30
uv run python main.py events --past           # include dates already passed
uv run python main.py models set synthesis --model zai/glm-4.7
uv run python main.py models reset all
uv run python main.py models cost --since 2026-09-01
uv run python main.py llm-log --id 42         # one call in full
uv run python main.py llm-log --trace 7       # every call in one operation
uv run python main.py llm-log --errors        # only attempts that failed
uv run python main.py thesis bootstrap        # theses from your own notes
uv run python main.py thesis bootstrap NKE --overwrite
uv run python main.py thesis show NKE
uv run python main.py thesis list
uv run python main.py triage --dry-run        # show the input, send nothing
uv run python main.py research AMD            # deep pass on one holding
uv run python main.py research AMD --no-store # run it for real, keep nothing
uv run python main.py recommend --no-store    # the same, for the decision stage
uv run python main.py decide 3 approve --note "agreed, buying Monday"
uv run python main.py decide 3 reject
uv run python main.py report --telegram       # send it to your phone
uv run python main.py weekly --telegram       # run everything and send it
uv run python main.py weekly --skip-research  # everything but the deep passes
uv run python main.py benchmark sync          # fetch the tracker's history
uv run python main.py daemon install          # one job: listens and schedules
uv run python main.py daemon stop
uv run python main.py daemon restart

Profiles

Every portfolio command is profile-scoped. --profile NAME selects one; omitting it means the operator profile in profiles.toml. One person is one profile: their own database, broker snapshot, context files and Telegram id.

uv run python main.py holdings --profile kasia
uv run python main.py init --profile kasia

A single Telegram bot serves everyone. The token is shared infrastructure in .env; the per-person part is the numeric telegram_id in the roster, which the bot routes incoming messages by.

--db PATH overrides the database on any command and bypasses the roster entirely — it exists for experimental databases. Only init creates a database; everything else fails with a message pointing at it, so a typo in a path or a profile name cannot silently produce an empty portfolio.

Context files

Each profile owns personal markdown files under $CASH_ASH_HOME/profiles/<name>/context/. profile add writes templates; context reports which are still templates and which you have written.

Thesis bootstrap reads written log.md, strategy.md and investor.md to restate the owner's reasons. recommend rereads strategy.md and investor.md on each run for the owner's horizon, cash needs and restrictions. It does not read the full log or watchlist. Missing, blank and template files supply no intent; decision inputs mark absent context explicitly. Personal context sent to a model is included in that call's stored log.

Market data

sync fetches one close per held security and one rate per currency they are quoted in, then stores both. It asks only about securities you actually hold — pricing something you sold costs a request and changes no figure.

Prices are stored in the security's own currency and converted at the stored FX rate, so a EUR return can be separated into stock move and currency move. Yahoo's USDEUR=X is requested directly rather than inverting EURUSD=X, because inverting is where direction errors live and an FX bug misprices every holding at once.

Anything the feed cannot price is reported, not guessed at. Use price to enter one by hand:

uv run python main.py price SPCX --close 147.95

The provider is unofficial and occasionally breaks. When a request fails outright, stored prices are left alone and holdings keeps using the last ones it had — marked stale, so a failing sync is visible rather than silent.

Events

sync records three kinds of date, and events lists what is coming.

Feed — earnings, ex-dividend and dividend dates, fetched automatically. If a company reschedules, the future date is replaced rather than added beside the old one; past dates are never touched, because a thesis that referenced one must still make sense.

Curated — everything no feed carries, and often what actually moves a holding: a product keynote, an IPO lockup expiry, a quarterly delivery report, a court date, a rate decision. These live in $CASH_ASH_HOME/profiles/<name>/events.toml; events.example.toml in the project root shows the format. sync imports the file and reports any entry it had to skip, so a typo surfaces rather than silently losing a date.

Research — written by the pipeline when a model finds a date. Deliberately the least trusted of the three.

Each entry is confirmed or estimated. An inferred date — a lockup expiry calculated from a listing date, a keynote in its usual slot — is marked ~ in the listing so it is never mistaken for something the company announced.

Consensus estimates

sync also records analyst EPS and revenue expectations, keyed by the date they were observed rather than the period they forecast. The provider reports what consensus is today and never what it was last month, so a revision is only detectable by comparing today's figure against one already stored. The series cannot be backfilled, which is why recording starts before anything reads it.

Theses

A thesis records why a position is held, and — the part that makes it useful — what would prove it wrong. A statement that cannot be falsified is a preference, not a thesis, and nothing downstream can detect that it stopped being true.

thesis bootstrap builds the first version of each from your own notes in context/log.md. It is a restatement, not research: the model is instructed to use only what you wrote, not to strengthen a weak reason, and not to soften a bad one. That matters because your real reasons are the baseline every later comparison is made against — a thesis you never held cannot break, and cannot teach you anything. The prompt permits empty assumption and breaking-condition lists. It restates explicit conditions or the direct negation of an explicit reason, without inventing thresholds or deadlines. Missing conditions become open questions; inspect the bootstrap with main.py thesis show before relying on it. An empty breaking-condition list is a debt the owner carries, not one the model pays: eval lists such theses as broken invariants until you write what would change your mind in context/log.md and rerun thesis bootstrap TICKER --overwrite.

Theses are versioned and never edited. A revision is a new version and the old one is kept, so a year later it is still possible to ask what was believed at the time and how it changed.

Conviction is an ordinal label — none, weak, moderate, strong — and never a number. An LLM's stated "confidence: 91%" is not a calibrated probability, and storing it as one would launder a guess into a statistic. Conviction describes the strength of the reason, not the quality of the company: a great business held for no articulated reason is weak.

Triage

triage ranks every holding by how likely it is that something changed which affects its thesis — not by how much the price moved or how large the position is. It is one call covering the whole portfolio rather than one per holding, because the judgement is comparative and is both cheaper and better made once with everything visible.

Every holding is recorded, including the ones passed over. "Nothing needed looking at this week" is a finding, and it is invisible if only the selected holdings are stored. A week with nothing selected is a normal outcome.

The model is told explicitly what it is not being given. Absent data and unchanged data look identical in the rendering, so without that a model reports calm it never observed — in the first weeks there is no price history and only one consensus observation, and neither means nothing moved. The prompt does not infer normal volatility, market divergence or the cause of a move from two stored closes. A thin thesis can warrant research without news; routine overdue coverage is also handled by the research rotation.

Two failure modes are handled rather than hidden. A holding the model omits is recorded as unranked, because a silently dropped holding looks exactly like one considered and passed over. And if nothing at all was ranked, the run fails loudly instead of reporting a triage that considered nothing as though it had run.

--dry-run prints exactly what would be sent and calls no model.

Deep research

research runs a plan, gathers evidence, answers the planned questions and judges whether the thesis still holds. With no ticker it researches whatever the last triage selected.

Planning is separate from answering on purpose. A generic question list would be identical for every company in every week and worth nothing; the planner works outward from this thesis — the conditions the owner said would change their mind, the questions left open when it was written, what it assumes without examining. It also states what it is deliberately leaving alone, since deciding something can be ignored is part of the job. Planner and analyst receive the research date. The analyst also receives the owner's full rationale and assumptions, and is asked to identify contradictions in supplied evidence without manufacturing an opposing case. Planner source suggestions do not drive automated retrieval; the default feed remains ticker news, with question-linked local excerpts available for targeted evidence.

Research proposes; it never adopts. When a pass concludes the thesis has weakened, improved or broken, it records a proposed revision beside the active one and stops. The thesis is a record of what you believe, so a pipeline able to rewrite it would be editing the baseline it is measured against. You review and accept.

broken means a condition you wrote down has actually occurred — not that the news was bad or the price fell. The prompt is explicit that an absence of evidence is reported as unchanged rather than turned into a verdict.

Watching a stage without changing anything

--no-store runs research or recommend exactly as the weekly cycle does — the same prompts, the same models, the same billing — against a copy of the database that is discarded when the command ends. Nothing is written except the model-call log, which is kept because the money was spent either way and llm-log is where that is accounted for. The log survives a run that fails partway, which is when it matters most.

uv run python main.py research AMD --no-store
uv run python main.py recommend --no-store

It exists because observing these two stages otherwise changes them. A research pass writes a run, its evidence and an assessment; recommend supersedes every recommendation you have not yet approved or rejected. Both are right for the weekly cycle and wrong for working out what the machinery is doing.

--model routes one --no-store run differently, so the same stage can be run on another model and the two compared in llm-log:

uv run python main.py research AMD --no-store --model analyst=zai/glm-4.7
uv run python main.py research AMD --no-store --model plan=zai/glm-4.7,analyst=zai/glm-4.7

Features are the stages main.py models lists. It is refused without --no-store: a stored result must always match the routing table, or that table stops explaining how a recorded result was produced. Use models set to change a real route.

Recommendations

recommend proposes actions for the week. The model proposes; deterministic rules in src/guardrails.py decide, in code, afterwards. That is the reason a model is allowed near this decision at all — a prompt asking it to respect a position limit is a request, and this is not.

Decision inputs include the latest stored native close with currency, date and source, FX into EUR with its date and source, available funded cash, reservations and pending executions. A close is not an executable broker quote. Financial statements, valuation metrics and transaction-cost estimates are not fetched for this stage; the model must identify material missing inputs rather than invent them. Known owner constraints supplement the deterministic rules and cannot relax them.

The prompt requires a reason to act now rather than a target level of activity. New funded cash can justify reconsidering an unchanged thesis, but cannot by itself justify a purchase. BUY/ADD must explain current valuation assumptions, relevant contrary evidence, material gaps and the comparison with keeping cash. REVIEW identifies whether resolution needs owner input, thesis adoption or more evidence. Purchase price and a desire to recover a loss do not justify a trade.

The deterministic checks enforce:

Refusals are stored and shown in the weekly report. "The model wanted to sell and the rules would not let it" is a different event from "the model recommended nothing", and the two must not look alike.

Every recommendation records the price and FX rate that stood behind it. That is the forward-tracking every later evaluation depends on, and it cannot be reconstructed after the fact.

Decisions and execution

decide records approve, reject or later. Approving is not executing — the gap is deliberate, because your strategy asks for a cooling-off period, and collapsing the two would erase the evidence of whether you acted at all.

Recommendations expire. A weekly cadence supersedes itself, so acting on a stale one would execute research that has already been replaced, at a price that has moved. decide refuses an expired recommendation and says why.

The weekly run

uv run python main.py weekly            # sync, triage, research, decide, report
uv run python main.py daemon install    # and have it run itself, weekly

One command, because five driven by hand is how a weekly habit fails to form.

Scheduling lives inside the daemon rather than in a second launchd job. There is one background process with two threads — a scheduler and the Telegram listener — following zdrowskit: one thing to install, one log, one thing that can be broken. Two jobs meant two ways to be half-working, with the listener running while the schedule was silently absent.

The scheduler asks a question about state — has a run been recorded for this ISO week? — rather than watching for a moment to pass. A machine asleep at the scheduled hour therefore runs on waking instead of skipping the week, and a daemon restarted twice in an hour does not run twice.

It also records what the portfolio was worth, right after refreshing prices. That value is derivable from dated trades and dated prices, so the snapshot is not the only record — but a derived figure silently changes when a price is later corrected, while a snapshot pins what was actually reported at the time, and it turns "when did this diverge from the index" into a query rather than a reconstruction. Unpriced holdings are omitted from it rather than recorded as zero, with the count kept in the snapshot's note.

Stages degrade rather than abort. A failed price sync leaves yesterday's prices and the run continues on them, marked stale. A research pass that fails on one holding does not stop the others. A failed decision still leaves a report to send. Abandoning the run on the first fault turns a partial answer into no answer, and the next attempt is seven days away.

RESEARCH_MAX_PASSES caps deep passes; --max-research overrides it for one cycle. RESEARCH_ROTATION_SLOTS reserves capacity for holdings older than RESEARCH_OVERDUE_DAYS, ordered by oldest completed assessment. Remaining slots follow triage priority; unused capacity checks additional overdue holdings. main.py process prints the effective limits. Deferred selections and unsupported instruments are reported explicitly. Company research supports RESEARCH_ASSET_CLASSES; other asset classes require manual review.

Scheduled for Sunday evening: the week's news has landed, markets are shut so nothing moves mid-run, and there is a day before Monday's open to think about anything proposed. The strategy asks for a cooling-off period, and a Sunday report gives one for free.

The single job is invoked through uv run rather than a fixed interpreter path, so a rebuilt virtual environment or an added dependency does not leave a job pointing at a stale interpreter that fails only at the next restart. launchd starts jobs with almost nothing in the environment, so both set HOME — every user-owned path here derives from it — and a PATH including the Homebrew locations, which the default omits entirely on Apple Silicon.

KeepAlive restarts the job on a crash but not on a clean exit. With no bot token there is nothing to listen for, so the listener does not start — but the scheduler still does, because a weekly review is worth having even when there is nowhere to send it, and everything it produces is readable from the CLI. Logs go to ~/Library/Logs, where Console.app looks.

Process checks

eval reports two different kinds of thing, and keeps them apart on purpose.

Invariants are statements that should never be true. An active thesis with nothing that would break it. A claim marked sourced without a URL and a date. A decision recorded against advice a later run withdrew. An active thesis the owner never accepted. A holding recorded as worth zero rather than omitted. Each is a defect with no tolerable rate, so each is reported as broken or not and the command exits non-zero when any is.

Two cover the chat agent's write path, which is the only place a model's reading of a sentence reaches the ledger. A proposal carrying a result must have been confirmed — a result on a cancelled or expired one means something applied a change the owner declined or never answered. And every cash flow or trade marked as coming from chat must have a confirmed proposal behind it, or a write reached the book around the buttons. A trade especially: it is wrong in every derived number until somebody notices. Neither says whether an answer was any good; that has no threshold worth inventing.

Possible percentage forecasts are an advisory observation listing matching recommendations, refusals, assessments and chat answers across all history. The regex can match negations or legitimate sourced statistics and miss other phrasings. Inspect the named rows; a match alone does not fail eval, and no match is not proof that model text complies with the probability rule.

Observations are numbers with no correct value: how many claims rest on a source, how many holdings are held on a thin reason, how often the rules refused a proposal, truncation and fallback rates, spend, coverage. For chat: questions asked, model calls per question, spend, and how proposed writes ended. Two calls a question is the ordinary shape — one to pick a tool, one to answer — so a rising figure means the loop is not converging. An expired proposal more often means the sentence did not make sense than that the owner changed their mind.

A note the agent appended that looks like it restates a figure the ledger holds — a weight, a value — is listed for review. Those are wrong within the week, because the note is replayed into later research while the ledger moves on. The owner's own numbers are legitimate, so this reads the line to you rather than failing. Two of them are split by prompt version — the sourced share per analyst prompt, and trades and REVIEWs per published decision batch per decide prompt, counting proposals the rules refused and completed batches with no proposals. Both prompt splits use the run date and the --weeks window. Attempts without a published batch are counted separately, including failures, invalid output and work still in progress; they do not represent deliberate inactivity. Model-call and cost metrics also use --weeks; other observations describe current state or stored history. These descriptive counts alone cannot attribute a change to a prompt or establish improvement. Process quality can be evaluated on controlled cases; investment skill is not established by these observations.

What eval deliberately does not measure is whether the advice was any good. Fourteen holdings a week will never produce the sample size for that, and replaying a past week cannot validate a model whose training already contains what happened next — a favourable historical result would measure leakage rather than judgement.

The benchmark

benchmark compares the portfolio against the same money left in a global tracker. This is the only measure of the system likely ever to mean much: at fourteen holdings a week nothing else has the sample size to separate skill from noise.

The comparison is money-weighted, not a return against a return. Money arrives over time, so "the portfolio is up 3% and the index is up 5%" answers a question nobody asked — it assumes every euro was present from the start. Instead each euro that entered the account buys tracker units at that day's close, building a shadow portfolio moved on exactly the same dates. Money that arrived in February cannot capture January's rise, in either column.

Cash flows internal to the account — dividends, fees — do not move the shadow, because no new money arrived. A contribution on a day the market was shut uses the previous close, which is what an investor could actually have done; requiring an exact match would silently drop those contributions instead.

The comparison says when it does not yet mean anything, and stops saying it on its own once enough time has passed. Recording begins in week one because the series cannot be reconstructed later; reading it as a verdict should not, and a number shown without that caveat invites exactly the mistake the system exists to avoid.

The weekly report

report renders review health, valuation coverage, pending actions, thesis proposals, blocked trade proposals and approved actions awaiting execution. --telegram sends action-specific buttons: acknowledge a review question, approve a trade, reject, snooze, view evidence or review the thesis.

A completed review with no pending actions is different from no review or a failed cycle. Stage outcomes persist, so /review continues to show failures. Missing holding prices suppress aggregate return; stale prices and FX are named. The first report after an upgrade has no verified cycle history until weekly runs. Opening an existing database applies the workflow migration automatically.

Holdings with no real reason behind them are reported separately, under "standing, not new". They are not this week's finding and never will be, and repeating them as though they were would be the generic-summary habit the whole design avoids.

One bot serves every profile. The token is shared infrastructure in .env and the per-person part is the numeric telegram_id in the roster, so nobody needs their own BotFather registration.

Sending is retried on a network fault but never on a refusal — a bad chat id or malformed markup fails identically every time, so retrying only delays the error. A message carrying buttons is never split, because the buttons would end up detached from what they act on.

Query views

db schema prints five views alongside the tables. They exist so SQL against the ledger does not have to rebuild the ledger's arithmetic:

View Holds
v_trades trades with the ticker, name, currency and sector spelled out
v_cash_flows cash flows, unchanged
v_cash_ledger every EUR movement in one signed column: a buy costs its fee, a sell returns net of it
v_cash_balance one balance per account, absent for an account that has never moved money
v_latest_price the newest stored close per security, in native currency

cash_eur reads v_cash_balance rather than recomputing the same sum in Python, so there is one definition of cash and not two.

Position cost basis is deliberately not a view. Average cost releases basis in proportion to the units held at that trade, which is path-dependent; it stays in portfolio.positions. v_latest_price is likewise not a valuation — it is native currency and says nothing about an unpriced security, because converting a price and reporting a missing one as None is what holdings is for.

Chat

Anything sent to the bot that does not start with / is a question, answered by a model with tools. Ask in plain words — "what did I pay for BRK.B", "how much cash", "when did I last add money", "which holdings have I never sold any of".

/reset forgets the conversation. The last twenty messages are kept in memory for follow-up questions and are lost when the daemon restarts; the portfolio itself is read from tools every turn, never remembered, because it changes and a buffer does not.

A question is answered on the profile's own worker thread rather than on the polling loop, so a turn taking several seconds does not stall button presses behind it. Two questions may wait; a third is refused with a message rather than queued, because queueing only makes the asker's own reply later. The bot sends "Working…" immediately and rewrites that message with the answer.

The model may call tools up to CHAT_MAX_TOOL_ITERATIONS times in one turn, then must answer with what it has. It defaults to the flash tier like every other stage — main.py models shows the route and main.py llm-log the cost. A turn is two calls and a fraction of a cent.

Charts

Ask to see something — "chart my weights", "show me the cash over time" — and the bot sends an image before the text. The model writes plotly code in a <chart> block against the rows it already fetched; the block is removed from what you read.

The code runs in a separate process with no filesystem, network or subprocess access, and imports allowlisted to plotly, numpy, math and datetime. A process rather than a thread because a thread cannot be killed: a runaway loop abandoned on one goes on burning a core and starves every later chart for the life of the daemon.

A chart that fails falls back to a plain bar or line drawn from the same rows, then to nothing. The answer's text is written to stand without the picture, so losing it costs detail and not meaning.

CHART_EXEC_TIMEOUT_S, CHART_WIDTH_PX and CHART_HEIGHT_PX are the knobs.

Proposed writes

Chat can also record two things, and records neither by itself. Say "topped up to €250", "got €1.20 from BRK.B", "took out €50", or "add to my log: bought more BRK.B because the float argument still holds", and the bot replies with one sentence and two buttons. Nothing changes until the tap.

Proposal Becomes
Cash moved a cash_flows row: contribution, withdrawal, dividend or fee
A trade a trades row, in a security already held
A note one dated line appended to log.md or watchlist.md

The sentence always states the resulting state — "cash goes €90.00 → €250.00" — because "topped up to 250" and "topped up 250" sound alike and differ by whatever the balance already was. A misreading is then visible before it is recorded rather than afterwards.

Proposals are validated when they are made, not when they are applied: a lower target balance cannot be a contribution, a future date is refused, and a movement of nothing is refused. What gets applied is the stored proposal, so the sentence agreed to and the write that follows are the same thing. Confirming twice writes once.

A trade is the most consequential of these, because every derived figure — position, cost basis, weights, cash — comes from the ledger. Its summary states the position and the cash on both sides: "Position goes 4 → 6 units; cash goes €50.00 → €20.00." A sell of more than is held is refused rather than capped; positions() caps an over-sell silently, and a proposal must not lean on that. A buy beyond the available cash is allowed and flagged — the broker executed it, so a negative balance means a deposit is missing from the book, not that the trade is wrong.

Say a price per share and it is converted at the latest stored rate, with that rate shown before you agree: "bought 2 at 470" on a US listing is 470 dollars, and a trade recorded at the wrong FX is a small error that never corrects itself. With no rate stored the proposal is refused and points at sync.

Only a security already held. Something new needs its listing currency and feed symbol set deliberately, or nothing can ever price it; there is no path for that from chat, and the agent says so instead of guessing. Correcting an existing trade is likewise not a chat operation — a wrong correction rewrites history and everything derived from it.

A proposal expires after PROPOSAL_TTL_MINUTES, because it is arithmetic against the balance at the time it was made; the daemon closes unanswered ones on its usual sweep. Ask again rather than confirming something stale — the figures are re-read.

strategy.md and investor.md cannot be appended to from chat. They constrain every recommendation the weekly run makes, so changing one is an edit to read in full, not a line added from a phone. history.md is generated.

Notes are held to one line that stands on its own, because they are replayed into later research prompts without the conversation that produced them. A note must not restate a value, weight or return: the ledger holds those and they will have moved by the time the note is read.

Chat answers what is and what happened. It does not say what to buy or sell — that comes from the weekly run, which has guardrails, sourced evidence and thesis review. Asked anyway, it says so and points at recommend. It states no probabilities, confidences or price targets, and does not judge the portfolio diversified or risky: it reports weights against the configured limits and leaves the judgement to the owner.

Chat tools

The chat agent reaches the database through three tools, and the split between them is deliberate.

Tool For
run_sql Read-only SQL: history, filtering, counting. Trades, cash flows, prices, recommendations, theses, research runs
portfolio_snapshot The authoritative current state: positions, cost basis, value, return, cash, total
concentration_report Weights by security, sector or theme, with the limits they are judged against
propose_cash_flow Asks to record money that moved. Writes nothing
propose_trade Asks to record a buy or sell in a held security. Writes nothing
propose_context_note Asks to append one line to log.md or watchlist.md. Writes nothing

run_sql opens the database mode=ro, so it cannot write and cannot create one. It accepts SELECT and WITH only, caps rows, truncates an oversized cell, and abandons a query that runs too long — the knobs are CASH_ASH_CHAT_SQL_ROW_LIMIT and CASH_ASH_CHAT_SQL_TIMEOUT_S. The schema it writes against is read from the live database on every turn rather than described in the prompt, so a migration cannot leave the two disagreeing.

Results come back as markdown tables and prose, not JSON. Repeating a key on every row costs tokens, and "value_eur": null leaves the reader to work out what it means where "UNPRICED — excluded from the total" says it.

The two propose_* tools return a validated proposal rather than applying it, which is what keeps every tool here read-only. The daemon persists it to pending_write and puts the buttons on it, and only the button writes.

SQL is not allowed to answer a money question. Cost basis is path-dependent and an unpriced holding reports nothing rather than zero, so a hand-written SUM(quantity * price) would produce a plausible wrong figure. Every euro amount, weight and return comes from portfolio_snapshot or concentration_report, which return what the rest of CashAsh computes. Both say which holdings they could not price and that the total excludes them.

The listener

Sending needs no daemon. Receiving does, because Telegram delivers updates by being asked for them and something has to keep asking.

uv run python main.py daemon           # foreground, Ctrl-C to stop
uv run python main.py daemon install   # under launchd, survives logout

daemon install writes a launchd job with KeepAlive, which is what makes the listener survive the machine sleeping — a connection dropped while asleep kills the poll, and without it the daemon would stay dead until noticed by hand. A crash loop is throttled rather than relaunched as fast as launchd can manage.

Every update must pass three tests before anything happens: the sender's numeric Telegram id is in the roster and enabled, the chat is private, and the chat id equals the sender id. An update failing any of them is ignored in silence — not answered, not acknowledged. The bot's username is discoverable, and replying would confirm the bot is live and tell a stranger their id is merely not on the list. A disabled profile is treated the same way.

The private-chat test is what stops a holding being read out into a group: the holder of a roster id can be in one with people who are not. In a private one-to-one chat the chat id and the sender id are the same number, so an update where they differ is not the conversation it claims to be. A roster telegram_id must be positive, because an update with no sender at all — a channel post, an anonymous admin — reads as id 0.

Button payloads are matched against a fixed anchored pattern rather than parsed. callback_data arrives from the network and is the one piece of user-controlled input that reaches a database write.

The update offset is advanced and persisted even when handling an update raised. A message that crashes the handler would crash it again on every restart, and a stuck offset means nothing after it is ever seen.

Two pollers steal each other's updates, so button presses would be handled at random. Telegram signals this with a 409, and the daemon stops rather than retrying — a failure that would otherwise present as buttons intermittently doing nothing.

Evidence and provenance

Every finding is sourced, background or unanswered. An unanswered question stays unanswered in storage. A sourced answer must reference a supplied evidence ID and copy an exact supporting excerpt. URL and date come from that item, not from model output. Unknown IDs and invented excerpts become unanswered findings. Membership and quote checks do not prove semantic support; inspect the source.

Coverage is sufficient only when every planned question has a sourced answer that passed citation validation. Background explanations remain stored, but cannot substitute for evidence about current company facts. Extra answers the analyst volunteers beyond the plan count neither for nor against coverage. On database migration, historical labels are recomputed under this rule; findings, evidence packages and owner theses are preserved. Even sufficient coverage does not establish that the questions addressed valuation or that an investment is attractive.

In practice the default news feed rarely sources every planned question, so BUY, ADD and EXIT stay refused until you supply targeted excerpts. research TICKER ends by listing the planned questions still without a sourced answer, verbatim, so they can be pasted into the questions field of an entry in context/evidence.json; the Telegram /evidence TICKER view shows the same list. main.py process reports the unanswered count across holdings. Each rerun generates a new plan. Question matching uses text containment, so a rephrased question can exclude a previously tagged excerpt. For relevant material you want available across plans, set its questions field to [] to match by symbol instead; the analyst still has to judge whether it answers the new questions. Reusing a previous plan is not currently supported.

Evidence packages are stored with a content hash alongside completed assessments, including unchanged assessments and open questions. These dated assessments feed the decision stage separately from the active thesis. A proposed thesis is never adopted automatically.

News aggregation is the default. For question-specific primary material, put a JSON list in context/evidence.json under your profile, or pass main.py research TEST --evidence-file /path/to/evidence.json. Local excerpts matching the symbol and question are considered before news. questions: [] applies an excerpt to any question for that symbol. Use the provider symbol.

[
  {
    "symbol": "TEST",
    "questions": ["What happened to operating margin?"],
    "title": "Synthetic quarterly release",
    "url": "https://example.com/investors/quarterly-release",
    "published": "2026-09-01",
    "publisher": "Synthetic issuer",
    "excerpt": "Operating margin was 12% for the quarter."
  }
]

These are supplied excerpts; the app does not automatically fetch filings or verify their transcription. Keep personal research under CASH_ASH_HOME. EVIDENCE_ITEMS_PER_SECURITY limits package size and EVIDENCE_MAX_AGE_DAYS excludes old material. main.py process prints both. Future-dated and malformed items are excluded. Unsupported instruments need manual review rather than an assumption that a company analyst can handle them.

Model routing

Each stage of the pipeline routes independently, so one expensive step does not drag the whole run's cost with it, and a change is attributable to the stage it was made in.

Every stage defaults to the flash tier. The pro tier is opt-in per feature:

uv run python main.py models                                  # what is in force
uv run python main.py models set synthesis --model zai/glm-4.7
uv run python main.py models cost                             # what it actually cost

Three tiers ship. flash and pro reason before answering; fast does not, and sits on a second provider. fast is the default fallback for that reason — a fallback within one provider survives a bad model but not an outage, which is the failure that would take a whole weekly run with it. It is redundancy, not a cheaper way to do the analysis: where the reasoning is the output, the reasoning is what is being paid for.

Preferences persist per profile, so two people can route differently. Any litellm model id is accepted, which is how a further provider joins without a code change.

Analysts default to temperature 0. Disagreement between them should come from using different models, not from sampling noise, or a rerun cannot tell the two apart.

Model call log

Every attempt is recorded, successful or not, with the model, prompt version, token counts, cost and the reasoning the model emitted. Failures are kept deliberately: a model that fails repeatedly is exactly what a later evaluation needs to see, and it is invisible if only successes are stored.

uv run python main.py llm-log            # recent calls
uv run python main.py llm-log --id 42    # prompt, reasoning, response, cost
uv run python main.py llm-log --trace 7  # every call in one weekly run

A trace groups the calls of one operation. Reading a synthesis in isolation says little about why it concluded what it did; reading it beside the analyst calls it consumed says a lot.

Two behaviours worth knowing, both measured rather than assumed. Output budgets have an enforced floor: the default model always reasons and cannot be told not to, so too small a budget is spent entirely on thinking and returns empty content that still costs money. And when a reply is truncated before any content appears, the budget is enlarged once and the fallback model is tried at that same enlarged size — a terser model can finish where a discursive one never stops thinking.

Reading the output

holdings values each position at the best price available: a stored market price converted at the stored FX rate, else the per-unit value implied by the most recent snapshot, else unknown. The footer names the dates the prices came from, flags any older than the staleness threshold in src/config.py, and lists anything that could not be priced. Unpriced holdings are excluded from the total and from every weight.

concentration reports three groupings. Security weights are checked against the position limit; sector and theme weights against the concentration alert level. Both limits are named in the table title and defined in src/config.py.

Theme weights overlap deliberately — NVDA is both a mega-cap and an AI semiconductor — so they sum past 100%. Splitting a holding's value between its themes would understate every one of them.

Seeding

init reads a broker snapshot from $CASH_ASH_HOME/seed_snapshot.toml. That file holds real holdings and euro amounts, so it lives beside the database and never in the repository; seed_snapshot.example.toml in the project root shows the format with invented figures.

Seeding is idempotent. Securities are upserted and the snapshot replaces any earlier one for the same date and source, so a mislabelled sector or theme is fixed by editing the file and re-running — not by rebuilding the database. Opening trades are written only when the ledger is empty, so re-running after real trades exist cannot double the book.

init refuses to write when the derived total drifts from the snapshot's reported_total_eur by more than the tolerance in src/config.py. Omitting reported_total_eur skips that check and says so.

Thesis review and execution

uv run python main.py thesis show TEST
uv run python main.py thesis accept TEST 2
uv run python main.py thesis reject TEST 3
uv run python main.py cash 25 --date 2026-09-07 --note "Actual deposit"
uv run python main.py decide 12 approve
uv run python main.py executed 12 --quantity 0.5 --amount 20 --fee 0.10 --date 2026-09-07
uv run python main.py executed 13 --not-executed --note "Changed my mind"
uv run python main.py process

Figures and IDs above are illustrative. thesis show labels the active version and pending revisions separately. Acceptance refuses a proposal based on a superseded thesis. Telegram equivalents are /thesis TEST, /accept TEST 2 and /reject TEST 3; /evidence TEST opens the latest findings and excerpts.

cash records an actual positive EUR contribution, never an expected deposit. decide ID approve rechecks trade constraints. A repeated identical decision is idempotent. Reject an approved action to release its reserved cash. An approval remains reserved after expiry until explicitly resolved.

decide ID later snoozes until SNOOZE_DAYS, bounded by the recommendation's expiry. It returns in /pending when due; the daemon sends a reminder on its next scheduler check. Expired or replaced recommendations are not reminded.

executed records one actual fill per approved trade recommendation and links it to the ledger atomically. Amount is EUR consideration excluding the fee. Oversells, duplicate execution, nonfinite values and invalid dates are refused. A recorded actual fill may differ from the proposal; it describes what happened. --not-executed closes an approved action without adding a trade. Partial fills across multiple executions and broker imports are not supported.

process prints observed coverage, unanswered questions, verified/rejected citations, decision reversals, recorded model cost and effective research limits. It measures process, not investment skill. Unknown model costs remain unknown.