beancount-cli 0.2.11__tar.gz → 0.2.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/CHANGELOG.md +23 -0
  2. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/PKG-INFO +3 -3
  3. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/pyproject.toml +3 -6
  4. beancount_cli-0.2.13/src/beancount_cli/__init__.py +1 -0
  5. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/price.py +45 -4
  6. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/report.py +2 -2
  7. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/transaction.py +8 -1
  8. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/models.py +15 -1
  9. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/services.py +41 -1
  10. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_cli.py +62 -2
  11. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_services.py +16 -0
  12. beancount_cli-0.2.11/src/beancount_cli/__init__.py +0 -1
  13. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/.gitignore +0 -0
  14. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/LICENSE +0 -0
  15. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/README.md +0 -0
  16. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/adapters.py +0 -0
  17. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/cli.py +0 -0
  18. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/__init__.py +0 -0
  19. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/account.py +0 -0
  20. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/commodity.py +0 -0
  21. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/common.py +0 -0
  22. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/commands/root.py +0 -0
  23. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/config.py +0 -0
  24. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/formatting.py +0 -0
  25. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/src/beancount_cli/py.typed +0 -0
  26. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/conftest.py +0 -0
  27. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/smoke_test.py +0 -0
  28. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_advanced.py +0 -0
  29. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_bql.py +0 -0
  30. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_config.py +0 -0
  31. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_coverage_gap.py +0 -0
  32. {beancount_cli-0.2.11 → beancount_cli-0.2.13}/tests/test_models.py +0 -0
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.13] - 2026-04-17
9
+
10
+ ### Added
11
+ - `price check`: detect anomalous pricing situations in addition to missing price gaps.
12
+ - `transaction list --fields`: limit JSON output to a selected comma-separated subset of fields.
13
+
14
+ ### Changed
15
+ - Upgraded `agentyper` to `0.1.12`.
16
+
17
+ ### Fixed
18
+ - `report audit`: sort entries chronologically from oldest to newest.
19
+ - `price fetch`: remove the non-functional `--held` behavior.
20
+
21
+ ## [0.2.12] - 2026-04-08
22
+
23
+ ### Fixed
24
+ - `price fetch --update`: cap `date_last` to yesterday (today exclusive) to avoid fetching intraday prices while the market is still open.
25
+
26
+ ## [0.2.11] - 2026-04-07
27
+
28
+ ### Fixed
29
+ - `price fetch`: use `[tool.uv.sources]` git override for `beanprice` so the romamo fork is installed correctly; PyPI metadata keeps `beanprice>=2.1.0` for compatibility.
30
+
8
31
  ## [0.2.10] - 2026-04-07
9
32
 
10
33
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beancount-cli
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: A CLI tool to manage Beancount ledgers
5
5
  Project-URL: Homepage, https://github.com/romamo/beancount-cli
6
6
  Project-URL: Repository, https://github.com/romamo/beancount-cli
@@ -20,9 +20,9 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Office/Business :: Financial :: Accounting
22
22
  Requires-Python: >=3.10
23
- Requires-Dist: agentyper==0.1.8
23
+ Requires-Dist: agentyper==0.1.12
24
24
  Requires-Dist: beancount>=3.0.0
25
- Requires-Dist: beanprice>=2.1.0
25
+ Requires-Dist: beanprice2>=2.1.0
26
26
  Requires-Dist: beanquery>=0.1.0
27
27
  Requires-Dist: pydantic-settings>=2.0.0
28
28
  Requires-Dist: pydantic>=2.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-cli"
3
- version = "0.2.11"
3
+ version = "0.2.13"
4
4
  description = "A CLI tool to manage Beancount ledgers"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -13,9 +13,9 @@ dependencies = [
13
13
  "beanquery>=0.1.0",
14
14
  "pydantic>=2.0.0",
15
15
  "python-dateutil>=2.8.2",
16
- "beanprice>=2.1.0",
16
+ "beanprice2>=2.1.0",
17
17
  "pydantic-settings>=2.0.0",
18
- "agentyper==0.1.8",
18
+ "agentyper==0.1.12",
19
19
  ]
20
20
  keywords = ["beancount", "cli", "accounting", "automation", "agent"]
21
21
  classifiers = [
@@ -62,8 +62,6 @@ packages = ["src/beancount_cli"]
62
62
  requires = ["hatchling"]
63
63
  build-backend = "hatchling.build"
64
64
 
65
- [tool.uv.sources]
66
- beanprice = { git = "https://github.com/romamo/beanprice" }
67
65
 
68
66
  [dependency-groups]
69
67
  dev = [
@@ -88,4 +86,3 @@ extend-immutable-calls = ["agentyper.Argument", "agentyper.Option", "typer.Argum
88
86
  [tool.ruff.lint.per-file-ignores]
89
87
  "tests/*" = ["E501"]
90
88
 
91
-
@@ -0,0 +1 @@
1
+ __version__ = "0.2.13"
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import sys
3
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
- from datetime import date, datetime, timedelta
4
+ from datetime import date, datetime
5
5
  from decimal import Decimal
6
6
  from pathlib import Path
7
7
  from tempfile import gettempdir
@@ -59,6 +59,49 @@ def price_check(
59
59
  typer.output(gaps, title="Price Gaps")
60
60
 
61
61
 
62
+ @app.command(name="check-anomalies")
63
+ def price_check_anomalies(
64
+ ledger_file: Path | None = typer.Argument(None, help="Path to ledger file"),
65
+ file: Path | None = typer.Option(
66
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
67
+ ),
68
+ threshold: float = typer.Option(
69
+ 1.0, "--threshold", "-t", help="Minimum change percentage as decimal (1.0 = 100%)"
70
+ ),
71
+ max_days: int = typer.Option(
72
+ 7, "--max-days", "-d", help="Maximum days between consecutive prices"
73
+ ),
74
+ ):
75
+ """Check for sudden price jumps or drops in the ledger."""
76
+ actual_file = get_ledger_file(ledger_file or file)
77
+ ledger_service = LedgerService(actual_file)
78
+ price_service = PriceService(ledger_service)
79
+
80
+ anomalies = price_service.get_price_anomalies(
81
+ threshold=Decimal(str(threshold)), max_days=max_days
82
+ )
83
+
84
+ if _is_table_format():
85
+ if not anomalies:
86
+ typer.echo("No price anomalies found.")
87
+ else:
88
+ anomalies_data = [
89
+ {
90
+ "Currency": a.currency,
91
+ "Target": a.target_currency,
92
+ "Date": str(a.date),
93
+ "Next Date": str(a.next_date),
94
+ "Price": f"{a.price:,.4f}",
95
+ "Next Price": f"{a.next_price:,.4f}",
96
+ "Change %": f"{a.change_pct:,.1f}%",
97
+ }
98
+ for a in anomalies
99
+ ]
100
+ typer.output(anomalies_data, title=f"Price Anomalies ({len(anomalies)})")
101
+ else:
102
+ typer.output(anomalies, title="Price Anomalies")
103
+
104
+
62
105
  @app.command(name="fetch", mutating=True)
63
106
  def price_fetch(
64
107
  ledger_files: list[Path] = typer.Argument(
@@ -123,9 +166,7 @@ def price_fetch(
123
166
 
124
167
  if update or fill_gaps:
125
168
  jobs = bp_price.get_price_jobs_up_to_date(
126
- entries,
127
- date_last=datetime.now().date() + timedelta(days=1),
128
- inactive=inactive,
169
+ entries, date_last=datetime.now().date(), inactive=inactive
129
170
  )
130
171
  else:
131
172
  jobs = bp_price.get_price_jobs_at_date(entries, date=None, inactive=inactive)
@@ -175,10 +175,10 @@ def report_audit(
175
175
  sys.exit(typer.EXIT_VALIDATION)
176
176
 
177
177
  txs = tx_service.list_transactions(currency=audit_currency)
178
- txs.sort(key=lambda x: x.date, reverse=True)
178
+ txs.sort(key=lambda x: (x.date, x.payee or "", x.narration))
179
179
 
180
180
  if not all_:
181
- txs = txs[:limit]
181
+ txs = txs[-limit:]
182
182
 
183
183
  if _is_table_format():
184
184
  from rich.table import Table
@@ -21,6 +21,9 @@ def tx_list(
21
21
  payee: str | None = typer.Option(None, "--payee", "-p", help="Filter by payee regex"),
22
22
  tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag"),
23
23
  where: str | None = typer.Option(None, "--where", "-w", help="Custom BQL where clause"),
24
+ fields: str | None = typer.Option(
25
+ None, "--fields", help="Comma-separated fields to include in JSON output"
26
+ ),
24
27
  ):
25
28
  """List transactions matching filters."""
26
29
  actual_file = get_ledger_file(ledger_file or file)
@@ -36,7 +39,11 @@ def tx_list(
36
39
  typer.output(data, title=f"Transactions ({len(txs)})")
37
40
  else:
38
41
  # Let agentyper format direct Pydantic models to JSON/CSV naturally!
39
- typer.output([tx.model_dump(mode="json") for tx in txs], title=f"Transactions ({len(txs)})")
42
+ results = [tx.model_dump(mode="json") for tx in txs]
43
+ if fields:
44
+ fields_set = set(fields.split(","))
45
+ results = [{k: v for k, v in r.items() if k in fields_set} for r in results]
46
+ typer.output(results, title=f"Transactions ({len(txs)})")
40
47
 
41
48
 
42
49
  @app.command(name="add")
@@ -1,9 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  import re
3
5
  from decimal import Decimal
4
6
  from typing import TYPE_CHECKING, Annotated, Any
5
7
 
6
- from pydantic import AfterValidator, BaseModel, Field
8
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field
7
9
 
8
10
 
9
11
  def validate_account_name(v: Any) -> str:
@@ -127,3 +129,15 @@ class PriceGapModel(BaseModel):
127
129
  last_available_date: datetime.date | None = None
128
130
  days_missing: int
129
131
  fetch_command: str | None = None
132
+
133
+
134
+ class PriceAnomalyModel(BaseModel):
135
+ model_config = ConfigDict(arbitrary_types_allowed=True)
136
+
137
+ currency: CurrencyCode.Input
138
+ target_currency: CurrencyCode.Input | None = None
139
+ date: datetime.date
140
+ next_date: datetime.date
141
+ price: Decimal
142
+ next_price: Decimal
143
+ change_pct: Decimal
@@ -14,6 +14,7 @@ from beancount_cli.models import (
14
14
  BalanceModel,
15
15
  CommodityModel,
16
16
  CurrencyCode,
17
+ PriceAnomalyModel,
17
18
  PriceGapModel,
18
19
  TransactionModel,
19
20
  UndeclaredCommodityModel,
@@ -790,7 +791,6 @@ class PriceService:
790
791
  jobs = bp_price.get_price_jobs_up_to_date(
791
792
  entries,
792
793
  date_last=date.today(),
793
- fill_gaps=True,
794
794
  update_rate=bp_rate,
795
795
  )
796
796
 
@@ -829,3 +829,43 @@ class PriceService:
829
829
  )
830
830
 
831
831
  return gaps
832
+
833
+ def get_price_anomalies(
834
+ self, threshold: Decimal = Decimal("1.0"), max_days: int = 7
835
+ ) -> list[PriceAnomalyModel]:
836
+ """
837
+ Identify sudden price jumps or drops.
838
+ threshold: 1.0 means 100% change (price doubled or dropped to zero).
839
+ max_days: Only compare points that are at most this many days apart.
840
+ """
841
+ self.ledger_service.load()
842
+ price_map = self.ledger_service.get_price_map()
843
+
844
+ anomalies = []
845
+ for (base, quote), points in price_map.items():
846
+ # points is a list of (date, price) sorted by date
847
+ for i in range(len(points) - 1):
848
+ curr_date, curr_price = points[i]
849
+ next_date, next_price = points[i + 1]
850
+
851
+ if (next_date - curr_date).days > max_days:
852
+ continue
853
+
854
+ if curr_price == 0:
855
+ continue
856
+
857
+ change = abs(next_price - curr_price) / curr_price
858
+ if change >= threshold:
859
+ anomalies.append(
860
+ PriceAnomalyModel(
861
+ currency=base,
862
+ target_currency=quote,
863
+ date=curr_date,
864
+ next_date=next_date,
865
+ price=curr_price,
866
+ next_price=next_price,
867
+ change_pct=change * 100,
868
+ )
869
+ )
870
+
871
+ return sorted(anomalies, key=lambda x: (x.currency, x.date))
@@ -1,5 +1,6 @@
1
1
  import io
2
2
  import json
3
+ import textwrap
3
4
  from unittest.mock import patch
4
5
 
5
6
  from beancount_cli.cli import main
@@ -28,6 +29,28 @@ def test_transaction_list(temp_beancount_file):
28
29
  assert "Employer" in out
29
30
 
30
31
 
32
+ def test_transaction_list_fields(temp_beancount_file):
33
+ code, out, err = run_cli(
34
+ "transaction",
35
+ "list",
36
+ str(temp_beancount_file),
37
+ "--format",
38
+ "json",
39
+ "--fields",
40
+ "date,payee",
41
+ )
42
+ assert code in (0, None)
43
+ data = json.loads(out)
44
+ # Check that only date and payee are present (plus any standard agentyper wrapper)
45
+ # If agentyper wraps it in {"ok": true, "data": [...]}:
46
+ txs = data["data"]
47
+ if isinstance(txs, list):
48
+ for tx in txs:
49
+ assert set(tx.keys()) == {"date", "payee"}
50
+ else:
51
+ assert set(txs.keys()) == {"date", "payee"}
52
+
53
+
31
54
  def test_transaction_add_json(temp_beancount_file):
32
55
  payload = {
33
56
  "date": "2023-12-01",
@@ -94,10 +117,47 @@ def test_report_holdings(temp_beancount_file):
94
117
  assert "Holdings" in out
95
118
 
96
119
 
97
- def test_report_audit(temp_beancount_file):
98
- code, out, err = run_cli("report", "audit", str(temp_beancount_file), "--currency", "USD")
120
+ def test_report_audit(tmp_path):
121
+ path = tmp_path / "audit.beancount"
122
+ path.write_text(
123
+ textwrap.dedent(
124
+ """
125
+ option "operating_currency" "USD"
126
+ 2020-01-01 open Assets:Cash USD
127
+ 2020-01-01 open Expenses:Food USD
128
+
129
+ 2020-02-01 * "Store A" "Oldest"
130
+ Expenses:Food 100 USD
131
+ Assets:Cash -100 USD
132
+
133
+ 2020-02-15 * "Store B" "Middle"
134
+ Expenses:Food 200 USD
135
+ Assets:Cash -200 USD
136
+
137
+ 2020-03-01 * "Store C" "Newest"
138
+ Expenses:Food 300 USD
139
+ Assets:Cash -300 USD
140
+ """
141
+ )
142
+ )
143
+ # Test all transactions (older to newest)
144
+ code, out, err = run_cli("report", "audit", str(path), "--currency", "USD", "--all")
99
145
  assert code in (0, None)
100
146
  assert "Audit Report: USD" in out
147
+ lines = [line for line in out.splitlines() if "Store" in line]
148
+ assert len(lines) == 6
149
+ assert "Store A" in lines[0]
150
+ assert "Store B" in lines[2]
151
+ assert "Store C" in lines[4]
152
+
153
+ # Test limit (should show LAST 2 transactions in chronological order: Middle -> Newest)
154
+ code, out, err = run_cli("report", "audit", str(path), "--currency", "USD", "--limit", "2")
155
+ assert code in (0, None)
156
+ lines = [line for line in out.splitlines() if "Store" in line]
157
+ assert len(lines) == 4
158
+ assert "Store B" in lines[0]
159
+ assert "Store C" in lines[2]
160
+ assert "Showing last 2 transactions" in out
101
161
 
102
162
 
103
163
  def test_tx_schema():
@@ -174,3 +174,19 @@ def test_holdings_in_target_currency_equal_position_amount(tmp_path):
174
174
  )
175
175
  assert holdings["totals"]["EUR"]["market"] == expected
176
176
  assert holdings["totals"]["EUR"]["cost"] == expected
177
+
178
+
179
+ def test_price_service_get_price_gaps(temp_beancount_file):
180
+ from unittest.mock import patch
181
+
182
+ from beancount_cli.services import LedgerService, PriceService
183
+
184
+ with patch("beanprice.price.get_price_jobs_up_to_date") as mock_get_jobs:
185
+ mock_get_jobs.return_value = []
186
+ ledger = LedgerService(temp_beancount_file)
187
+ svc = PriceService(ledger)
188
+ # Using a default call to get_price_gaps to see if it defaults correctly
189
+ svc.get_price_gaps()
190
+ mock_get_jobs.assert_called_once()
191
+ # Verify that fill_gaps is not among the kwargs anymore (since it was removed)
192
+ assert "fill_gaps" not in mock_get_jobs.call_args[1]
@@ -1 +0,0 @@
1
- __version__ = "0.2.11"
File without changes
File without changes