Skip to main content

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 (dev). 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+.

git clone git@github.com:YemaneSG/SpendSignal.git
cd SpendSignal
python3 -m venv .venv && source .venv/bin/activate
pip install .

# 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

If you have uv, replace the venv + pip install steps with uv sync.

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; or `uv sync` if you have uv

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

spendsignal-0.3.0.tar.gz (46.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spendsignal-0.3.0-py3-none-any.whl (29.9 kB view details)

Uploaded Python 3

File details

Details for the file spendsignal-0.3.0.tar.gz.

File metadata

  • Download URL: spendsignal-0.3.0.tar.gz
  • Upload date:
  • Size: 46.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.15

File hashes

Hashes for spendsignal-0.3.0.tar.gz
Algorithm Hash digest
SHA256 41b641dab3e62eb36428d278b71eba546da4d026ce67f3337097174f089a19f5
MD5 514f293cafffdb47ec4e5daa57befb87
BLAKE2b-256 27e84da6f3ac643b63c8015b0a72013425f791dc438227c28270e8eaa452fbf9

See more details on using hashes here.

File details

Details for the file spendsignal-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: spendsignal-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 29.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.15

File hashes

Hashes for spendsignal-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 eea50f1e2383b6074a32022bc4d6d6a5c73cdaf7ad9087fcec4d7aed4da7c310
MD5 c1e80ccaed164ec9c73efd1b8fb5737d
BLAKE2b-256 228176d9cd23b1e48a53e3666b864d752907a21eaeaa3bf451303c77b88609cb

See more details on using hashes here.

Release history Release notifications | RSS feed

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

This release

0.3.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page