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

spendsignal-0.4.0.tar.gz (49.4 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.4.0-py3-none-any.whl (33.1 kB view details)

Uploaded Python 3

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

Hashes for spendsignal-0.4.0.tar.gz
Algorithm Hash digest
SHA256 9b3b38ce5f29786316c574e783ed80d82519d96ddaf7958f062479879ca51faf
MD5 8196e91f5011e5b579a94b88ee79bb83
BLAKE2b-256 6533672b9a770481f5a8b67b7c00141285557fee1bfb23a8cfb14969a681524c

See more details on using hashes here.

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

Hashes for spendsignal-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bd873157c81c244bcbb1f3adb35f840673319199e8ec61709799a1cde860ed63
MD5 7f2c315ea27b9661169cba037410f7c7
BLAKE2b-256 0d55063826e9fd54726de92b5270620b36462dceccd656754d5fa153bcbf9bfe

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

This release

0.4.0 This release

2 files

0.3.0

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