Metadata-Version: 2.5
Name: predecimal
Version: 0.1.0
Summary: Exact arithmetic for pre-decimal British currency (pounds, shillings and pence)
Project-URL: Homepage, https://github.com/sakulik2/predecimal
Project-URL: Issues, https://github.com/sakulik2/predecimal/issues
Project-URL: Changelog, https://github.com/sakulik2/predecimal/blob/main/CHANGELOG.md
License-Expression: MIT
License-File: LICENSE
Keywords: currency,history,lsd,numismatics,pounds-shillings-pence,sterling
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Office/Business :: Financial :: Accounting
Classifier: Topic :: Sociology :: History
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: hypothesis>=6; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Provides-Extra: test
Requires-Dist: hypothesis>=6; extra == 'test'
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# predecimal

[![CI](https://github.com/sakulik2/predecimal/actions/workflows/ci.yml/badge.svg)](https://github.com/sakulik2/predecimal/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/predecimal)](https://pypi.org/project/predecimal/)
[![Python versions](https://img.shields.io/pypi/pyversions/predecimal)](https://pypi.org/project/predecimal/)

Exact arithmetic for pre-decimal British currency — pounds, shillings and pence.

Every amount is stored as an integer number of farthings (¼d), so arithmetic is
exact. No floating point, no drift, and no dependencies.

```
1 pound = 20 shillings = 240 pence = 960 farthings
```

```python
from predecimal import LSD, GUINEA, HALF_CROWN

LSD(0, 2, "11½") * 37          # 37 items at 2s 11½d each
# LSD('£5 9s 5½d')

LSD.parse("2/11½") * 37        # same thing, shop slash notation
# LSD('£5 9s 5½d')

(LSD(0, 2, "11½") * 37).slash()
# '£5 9/5½'
```

## Install

```bash
pip install predecimal
```

Requires Python 3.9+. Zero runtime dependencies.

## Decimalisation has four right answers

This is the part other currency libraries get wrong, and the main reason this one
exists.

One old penny is 5/12 of a new penny, so **no £sd amount below 2s is a whole
number of new pence**. Every crossing into post-1971 sterling needs a stated
rounding policy, and the policies genuinely disagree. Half a crown (2s 6d) is:

| Method | Result | What it is |
|---|---|---|
| `to_pounds()` | `Fraction(1, 8)` | Exact. A `Fraction`, never rounded. |
| `to_decimal()` | `Decimal('0.13')` | Nearest new penny. |
| `to_shop_new_pence()` | `Fraction(25, 2)` | 12½p — 1971 counter practice, nearest ½p. |
| `to_statutory_new_pence()` | `13` | Decimal Currency Act 1969, Schedule 1. |

```python
HALF_CROWN.to_pounds()               # Fraction(1, 8)    — exact
HALF_CROWN.to_decimal()              # Decimal('0.13')   — nearest new penny
HALF_CROWN.to_shop_new_pence()       # Fraction(25, 2)   — 12½p, the shop rule
HALF_CROWN.to_statutory_new_pence()  # 13                — the legal conversion
```

None of these is a bug. Pick the one that matches your source:

- **`to_pounds`** / **`to_new_pence`** return exact `Fraction`s and are the only
  lossless path. `from_new_pence(x.to_new_pence()) == x` always holds.
- **`to_decimal`** rounds to the nearest new penny, half away from zero. The
  rounding mode is settable.
- **`to_shop_new_pence`** works in halfpennies, as shop prices did in 1971 when
  the ½p coin still existed: 6d = 2½p exactly, 1d ≈ ½p.
- **`to_statutory_new_pence`** is the bank and accounting conversion set out in
  law. Each whole 2s becomes 10p and the sub-2s remainder comes from a fixed
  table that rounds up and down alternately so errors cancel across a ledger.
  1d is disregarded entirely, and 6d becomes 3p rather than its exact 2½p. It is
  **not** a nearest-value conversion.

Going back, `from_decimal` and `from_new_pence` round to the nearest farthing.
`from_decimal` is not a true inverse of `to_decimal`: a farthing is £1/960, so
decimal pounds land between farthings.

Both reject `float` with a `TypeError` — `0.1` is not 1/10 in binary, and the
error has no business entering an exact type. Pass `Decimal`, `str`, `int` or
`Fraction`.

## Notation

Three input notations, all through `LSD.parse`:

```python
LSD.parse("£5 9s 5½d")      # unit notation, order-free
LSD.parse("2/11½")          # slash notation
LSD.parse("10/-")           # ten shillings exactly
LSD.parse("3 gns 10s 6d")   # a leading count of a named unit
```

Three output formats:

```python
str(LSD(5, 9, "5½"))                  # '£5 9s 5½d'
LSD(5, 9, "5½").slash()               # '£5 9/5½'
LSD(3, 13, 6).named(GUINEA)           # '3 gns 10s 6d'
f"{LSD(5, 9, '5½'):/>12}"             # '     £5 9/5½'  — "/" selects slash style
```

Parsing is strict on purpose. A bare leading number is ambiguous (`"5 9s 5½d"` —
is 5 pounds or shillings?) and is rejected, as is a repeated unit or any leftover
text. Decimal pounds are rejected too: `LSD.parse("£5.50")` raises, because
crossing from decimal has to go through `from_decimal` where the policy is
explicit.

## Coins and units of account

```python
from predecimal import GUINEA, SOVEREIGN, CROWN, HALF_CROWN, FLORIN, MARK, NOBLE

GUINEA * 37                  # LSD('£38 17s 0d')
LSD(2, 2, 0) / GUINEA        # 2 — a plain ratio
LSD(2, 0, 0).named(MARK)     # '3 marks'
LSD(3, 13, 6).in_units(GUINEA)   # (3, LSD('£0 10s 6d'))
```

`Unit` subclasses `LSD`, so a named denomination **is** an amount and needs no
separate arithmetic. Note that arithmetic returns a plain `LSD`, not a `Unit` —
`type(GUINEA * 2) is LSD`. This is deliberate: the result of multiplying a guinea
is an amount of money, not a named denomination.

`named()` is meant for units of account (guinea, mark, noble, crown). Using it
with `PENNY`, `HALFPENNY` or `FARTHING` produces odd output like `"5 d"`, since
all three abbreviate to `d`.

The retariffed gold denominations (angel, ryal, spur ryal) are deliberately
absent: their value depends on the document's date, so a single constant would be
wrong more often than right.

## Splitting money

```python
divmod(LSD(1, 0, 0), 3)      # (LSD('£0 6s 8d'), LSD('£0 0s 0d'))
LSD(0, 0, 1) // 3            # LSD('£0 0s ¼d') — a penny three ways
LSD(0, 0, 1) % 3             # LSD('£0 0s ¼d') — with a farthing left over
sum([GUINEA, HALF_CROWN])    # LSD('£1 3s 6d')
```

Rounding is half **up**, away from zero, as money conventionally rounds — not
Python's half-to-even `round()`. Amounts that do not land on a whole farthing
raise `ValueError` rather than rounding silently.

`round()` is deliberately not implemented, since it would have to choose a unit
silently. Use `round_to`:

```python
LSD(0, 2, 7).round_to(SHILLING)   # LSD('£0 3s 0d')
```

## Details worth knowing

- `LSD` is immutable and hashable; `farthings` is public, stable API.
- Pickling and copying work. Round-tripping yields an equal object, not the same
  one: `pickle.loads(pickle.dumps(GUINEA)) == GUINEA` is `True`, `is` is `False`.
- `to_statutory_new_pence` implements the *amount* conversion. Converting a
  historical bank balance carried one extra rule — balances ending 19s 11d were
  rounded up by 1d first — which you must apply yourself.
- The `¼½¾` and `£` glyphs need a UTF-8 console. On Windows, set
  `PYTHONUTF8=1` if your terminal uses a legacy codepage.

## Development

```bash
pip install -e ".[dev]"
pytest                    # tests plus the doctests in the module docstring
python tests/test_core.py  # same tests, no pytest needed
python -m predecimal      # a worked example
mypy src/
ruff check .
```

## Licence

MIT
