SpendSignal
A small local-first library for logging line-item purchases with your own retrospective outcomes, and retrieving that history when you're about to buy something similar.
v0.3.0 · PyPI · Releases · MIT
Runs on synthetic data. No cloud, no accounts, no API keys.
Non-technical reader? Start with docs/what-is-spendsignal.md - a plain-English walkthrough with a concrete example and the research ideas behind the design.
Try it in 5 minutes
Requires Python 3.12+.
pip install spendsignal
git clone git@github.com:YemaneSG/SpendSignal.git
cd SpendSignal
# Given my three past protein-powder purchases, what does my current view say?
spendsignal summarize \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl \
--subject protein-powder \
--as-of 2026-12-01T00:00:00+00:00
The package is on PyPI — pip install spendsignal works without cloning. Clone only if you want to run the committed fixtures or browse the source. Optional extras: spendsignal[mcp] for the MCP server, spendsignal[actual] for Actual Budget, spendsignal[all] for everything.
Real output:
{
"schema_version": "1",
"generated_at": "2026-12-01T00:00:00Z",
"query": {"subject": "protein-powder", "as_of": "2026-12-01T00:00:00Z"},
"coverage": {
"comparable_purchase_count": 3,
"exposure_count": 3,
"feedback_count": 6
},
"distributions": [
{"axis": "worth_it", "count": 3, "distribution": {"5": 2, "4": 1}},
{"axis": "would_buy_again", "count": 3, "distribution": {"true": 3}}
],
"contradictions": [],
"revisions": 0,
"contributing_event_ids": [
"fb-p1-again", "fb-p1-worth",
"fb-p2-again", "fb-p2-worth",
"fb-p3-again", "fb-p3-worth"
],
"abstention": null
}
Three past purchases, six feedback events, no contradictions, no revisions. Every event ID supporting the summary is enumerated. Nothing invented.
What SpendSignal does
- Logs line items with typed retrospective labels (
would_buy_again,worth_it,still_using,returned,regretted,planned, ...). - Runs locally on synthetic data. No cloud, no accounts, no API keys required for the demo.
- Deterministic first, LLM second. Financial truth lives in typed data structures. LLMs interpret.
- Interoperates with mature tools (Actual Budget for the ledger, existing OCR libraries for image ingest, MCP for assistant surfaces) instead of replacing them.
What SpendSignal is not
- Not a budgeting app. No envelopes, no forecasts, no net worth.
- Not an accounting engine. Use Actual, hledger, or beancount.
- Not an AI financial advisor. Nothing here recommends stocks or predicts markets.
- Not a receipt-photo app. Ingest lives at the edges. This library is what happens after the receipt is text.
The loop
bank transaction
→ matched receipt
→ individual line items
→ your retrospective labels (would_buy_again, worth_it, still_using,
returned, regretted, planned, ...)
→ a queryable log of what actually happened
→ retrieval when you're about to buy something similar
→ new outcome feedback
Retrieval over your own past, not prediction. The tool does not decide whether you should buy something. You do.
Point-in-time is real
The same query at an earlier --as-of returns the state as of that date. In the fixture, the wireless-headphones feedback was initially positive in July and revised to negative in October via supersession. Ask "as of August" and you get the pre-revision state.
# Latest view - both labels revised, revisions=2
spendsignal summarize \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl \
--subject wireless-headphones \
--as-of 2026-12-01T00:00:00+00:00
# → distributions: worth_it {1: 1}, would_buy_again {false: 1}, revisions: 2
# Earlier view - original opinion, revisions=0
spendsignal summarize \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl \
--subject wireless-headphones \
--as-of 2026-08-01T00:00:00+00:00
# → distributions: worth_it {4: 1}, would_buy_again {true: 1}, revisions: 0
History is append-only. Old opinions are preserved next to new ones. The past does not change.
Explicit abstention when evidence is insufficient
SpendSignal refuses to invent a score when it has none. Ask about a subject with a comparable purchase but no feedback and you get an explicit refusal with a reason and a still-populated coverage count.
spendsignal summarize \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl \
--subject chicken \
--as-of 2026-12-01T00:00:00+00:00
{
"coverage": {"comparable_purchase_count": 1, "exposure_count": 1, "feedback_count": 0},
"distributions": [],
"revisions": 0,
"contributing_event_ids": [],
"abstention": {
"reason": "no_feedback",
"detail": "Comparable purchases exist but no feedback has been recorded on them."
}
}
Three abstention reasons are possible - no_comparable_purchases, no_feedback, no_feedback_within_scope - each with a human-readable detail.
Adapters
Two adapters ship in Phase 2, building on the same evidence contracts. Both stdlib-only — no extra dependencies.
spendsignal extract — receipt text → PurchaseEvent
Convert raw receipt text into a PurchaseEvent. Ships with a deterministic mock (default) and a stdlib regex TextParserProvider. Invoice2DataProvider available under pip install spendsignal[extract].
from spendsignal.adapters.extract import TextParserProvider, extract
ev = extract("COSTCO\n01/15/2026\nProtein powder $49.99\nTotal $49.99", "receipt-001")
# PurchaseEvent with line_items and comparison_keys
ev = extract("receipt text", "r1") # MockExtractionProvider by default — CI-safe
spendsignal retrieve — "have I bought anything like this?"
Fuzzy-matches a free-text query against all known comparison_keys in your purchase history, then returns each matched subject's EvidenceSummary.
spendsignal retrieve \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl \
--query "protein shake" \
--as-of 2026-12-01T00:00:00+00:00 \
--top-n 3
Returns a JSON array. Each element has comparison_key, similarity, and a full EvidenceSummary. Abstentions pass through - if a match has no feedback yet, the result still surfaces it with abstention.reason = "no_feedback" rather than silently dropping it.
Hyphens and spaces normalised: "protein shake" matches "protein-powder" near 1.0.
spendsignal match — pair bank transactions with receipts
Deterministic amount + date + fuzzy merchant scoring. Amount and date are hard gates; merchant is a soft ranking signal. Greedy highest-confidence assignment. One-to-one.
spendsignal match \
--transactions fixtures/transactions.jsonl \
--purchases fixtures/purchases.jsonl
txn-2026-08-15-costco <-> costco-3 confidence=1.0 (exact amount, date, merchant)
txn-2026-06-15-costco <-> costco-2 confidence=0.9118 (exact amount, date, fuzzy merchant)
txn-2026-01-15-costco <-> costco-1 confidence=0.7667 (exact amount, 1-day gap, fuzzy merchant)
The SHELL transaction has no matching receipt and stays unmatched.
spendsignal serve — MCP server
Exposes summarize_subject, retrieve_similar, and match_receipts as MCP tools. Compatible with Claude Desktop, Continue, Cursor, and any MCP-capable LLM host.
pip install spendsignal[mcp]
spendsignal serve \
--purchases fixtures/purchases.jsonl \
--exposures fixtures/exposures.jsonl \
--feedback fixtures/feedback.jsonl
Add to your Claude Desktop claude_desktop_config.json:
{
"mcpServers": {
"spendsignal": {
"command": "spendsignal",
"args": [
"serve",
"--purchases", "/path/to/purchases.jsonl",
"--exposures", "/path/to/exposures.jsonl",
"--feedback", "/path/to/feedback.jsonl"
]
}
}
}
Then ask Claude: "What does my purchase history say about protein powder?"
Actual Budget integration
Pull bank transactions directly from a running Actual Budget instance for use with the matching adapter.
from spendsignal.adapters.actual_budget import ActualBudgetProvider, load_actual_transactions
# Mock — no credentials needed (default for demos and CI)
txns = load_actual_transactions()
# Real Actual Budget (requires pip install spendsignal[actual])
provider = ActualBudgetProvider(
server_url="http://localhost:5006",
password="your-password",
budget_id="your-budget-id",
)
txns = load_actual_transactions(provider=provider)
Library use
from spendsignal.adapters.extract import TextParserProvider, extract
from spendsignal.adapters.retrieve_nn import retrieve
from spendsignal.adapters.match_deterministic import BankTransaction, Receipt, match
from spendsignal.adapters.actual_budget import load_actual_transactions
See src/spendsignal/adapters/ and fixtures/README.md for usage examples.
Library use
spendsignal.summarize is a pure function that takes iterables of validated pydantic models and returns an EvidenceSummary.
from datetime import UTC, datetime
from spendsignal import summarize
from spendsignal.storage import load_exposures, load_feedback, load_purchases
summary = summarize(
purchases=load_purchases("fixtures/purchases.jsonl"),
exposures=load_exposures("fixtures/exposures.jsonl"),
feedback=load_feedback("fixtures/feedback.jsonl"),
subject="protein-powder",
as_of=datetime(2026, 12, 1, tzinfo=UTC),
)
print(summary.model_dump_json(indent=2))
Same inputs always produce byte-identical output. generated_at defaults to as_of for reproducibility - pass now= when a real wall clock matters.
Contracts
Four JSON schemas under schemas/ define the cross-language wire format.
| Schema | What it carries |
|---|---|
purchase-event |
Source-neutral purchase record with optional line items and caller-supplied comparison keys. |
reflection-exposure |
Records that the user was asked for feedback about a purchase or line item. Selection reason and policy version enable bias detection. |
feedback-event |
One axis of retrospective feedback per event. Append-only; revisions use supersedes_event_id. Discriminated value: bool / likert_5 / categorical. |
evidence-summary |
Deterministic output shape. Coverage, per-axis distributions, contradictions, revisions, contributing event IDs, explicit abstention. |
JSON Schema Draft 2020-12. Python consumers use the pydantic mirrors in src/spendsignal/models.py. Other languages read the schemas directly.
Development
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
pip install ruff pytest hypothesis mypy # dev tools
pytest -q # 113 tests, under 1 second
ruff check .
ruff format --check .
mypy
Regenerate the fixtures:
python scripts/generate_fixtures.py
Status
Phase 2 and Phase 3 complete. Five adapters shipped: match_deterministic, retrieve_nn, extract, mcp_server, actual_budget. See ROADMAP.md for what's next.
Design decisions are recorded as ADRs in docs/decisions/. The load-bearing one is ADR-0002 - the v0.1 module boundary.
License
MIT. See LICENSE and ADR-0004.
Contributing
See CONTRIBUTING.md.
Security
See SECURITY.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file spendsignal-0.4.0.tar.gz.
File metadata
- Download URL: spendsignal-0.4.0.tar.gz
- Upload date:
- Size: 49.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b3b38ce5f29786316c574e783ed80d82519d96ddaf7958f062479879ca51faf
|
|
| MD5 |
8196e91f5011e5b579a94b88ee79bb83
|
|
| BLAKE2b-256 |
6533672b9a770481f5a8b67b7c00141285557fee1bfb23a8cfb14969a681524c
|
File details
Details for the file spendsignal-0.4.0-py3-none-any.whl.
File metadata
- Download URL: spendsignal-0.4.0-py3-none-any.whl
- Upload date:
- Size: 33.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd873157c81c244bcbb1f3adb35f840673319199e8ec61709799a1cde860ed63
|
|
| MD5 |
7f2c315ea27b9661169cba037410f7c7
|
|
| BLAKE2b-256 |
0d55063826e9fd54726de92b5270620b36462dceccd656754d5fa153bcbf9bfe
|