Skip to content

Commit 02b7428

Browse files
authored
New day counter (#55)
* Bump deps and use uv * Better day counters * vibe coding * vibe coding
1 parent 2e4b058 commit 02b7428

13 files changed

Lines changed: 540 additions & 104 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ applyTo: '/**'
2121
* The documentation for ccy is available at `https://ccy.quantmid.com`
2222
* Documentation is built using [mkdocs](https://www.mkdocs.org/) and stored in the `docs/` directory. The documentation source files are written in markdown format.
2323
* Do not use em dashes (—) in documentation files or docstrings. Use colons, parentheses, or restructure the sentence instead.
24+
* To link to a class or function in documentation, use the mkdocstrings cross-reference notation: `[ClassName][module.path.ClassName]` (e.g. `[TradingCentre][ccy.tradingcentres.TradingCentre]`)

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ docs: ## Build mkdocs site
3838

3939
.PHONY: docs-serve
4040
docs-serve: ## Serve docs locally with live reload
41-
uv run mkdocs serve
41+
uv run mkdocs serve --livereload --watch ccy --watch docs
4242

4343
.PHONY: publish-docs
4444
publish-docs: ## Publish docs to github pages

ccy/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
currencydb,
2121
dump_currency_table,
2222
)
23-
from .core.daycounter import alldc, getdc
23+
from .core.daycounter import DayCounter
2424
from .dates.converters import (
2525
date2juldate,
2626
date2timestamp,
@@ -42,8 +42,7 @@
4242
"currency_pair",
4343
"dump_currency_table",
4444
#
45-
"getdc",
46-
"alldc",
45+
"DayCounter",
4746
#
4847
"country",
4948
"countryccy",

ccy/core/daycounter.py

Lines changed: 48 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,58 @@
1-
"""Day Counter for Counting time between 2 dates.
2-
Implemented::
3-
4-
* Actual 360
5-
* Actual 365
6-
* 30 / 360
7-
* Actual Actual
8-
"""
9-
101
from __future__ import annotations
112

12-
from copy import copy
133
from datetime import date
14-
from typing import Any
15-
from ..dates.utils import date_diff
16-
17-
__all__ = ["getdc", "DayCounter", "alldc"]
18-
19-
20-
def getdc(name: str) -> DayCounter:
21-
return _day_counters[name]()
22-
23-
24-
def alldc() -> dict[str, DayCounterMeta]:
25-
global _day_counters
26-
return copy(_day_counters)
27-
28-
29-
class DayCounterMeta(type):
30-
def __new__(cls, name: str, bases: Any, attrs: Any) -> DayCounterMeta:
31-
new_class = super(DayCounterMeta, cls).__new__(cls, name, bases, attrs)
32-
if name := getattr(new_class, "name", ""):
33-
_day_counters[name] = new_class
34-
return new_class
35-
36-
37-
_day_counters: dict[str, DayCounterMeta] = {}
38-
39-
40-
class DayCounter(metaclass=DayCounterMeta):
41-
name: str = ""
42-
43-
def count(self, start: date, end: date) -> float:
44-
"""Count the number of days between 2 dates"""
45-
return date_diff(end, start).total_seconds() / 86400
46-
47-
def dcf(self, start: date, end: date) -> float:
48-
return self.count(start, end) / 360.0
49-
50-
51-
class Act360(DayCounter):
52-
name = "ACT/360"
53-
54-
55-
class Act365(DayCounter):
56-
name = "ACT/365"
57-
58-
def dcf(self, start: date, end: date) -> float:
59-
return self.count(start, end) / 365.0
4+
from enum import StrEnum
605

6+
from ..dates.utils import date_diff
617

62-
class Thirty360(DayCounter):
63-
name = "30/360"
8+
__all__ = ["DayCounter"]
649

65-
def dcf(self, start: date, end: date) -> float:
66-
d1 = min(start.day, 30)
67-
d2 = min(end.day, 30)
68-
return 360 * (end.year - start.year) + 30 * (end.month - start.month) + d2 - d1
6910

11+
class DayCounter(StrEnum):
12+
"""Day count convention types"""
7013

71-
class ActAct(DayCounter):
72-
name = "ACT/ACT"
14+
ACT360 = "ACT/360"
15+
ACT365 = "ACT/365"
16+
THIRTY360 = "30/360"
17+
THIRTYE360 = "30E/360"
18+
ACTACT = "ACT/ACT"
7319

7420
def dcf(self, start: date, end: date) -> float:
75-
return self.act_act_years(end) - self.act_act_years(start)
76-
77-
def act_act_years(self, dt: date) -> float:
78-
y = dt.year
79-
days_in_year = 365 if y % 4 else 366
80-
dd = date_diff(dt, date(y, 1, 1)).total_seconds() / 86400
81-
return y + dd / days_in_year
21+
"""Day count fraction between 2 dates"""
22+
match self:
23+
case DayCounter.ACT360:
24+
return count_days(start, end) / 360.0
25+
case DayCounter.ACT365:
26+
return count_days(start, end) / 365.0
27+
case DayCounter.THIRTY360:
28+
return _thirty_360(start, end)
29+
case DayCounter.THIRTYE360:
30+
return _thirty_e360(start, end)
31+
case DayCounter.ACTACT:
32+
return _act_act_years(end) - _act_act_years(start)
33+
case _:
34+
raise ValueError(f"Unknown day counter: {self}")
35+
36+
37+
def count_days(start: date, end: date) -> float:
38+
"""Count the number of days between 2 dates"""
39+
return date_diff(end, start).total_seconds() / 86400
40+
41+
42+
def _thirty_e360(start: date, end: date) -> float:
43+
d1 = min(start.day, 30)
44+
d2 = min(end.day, 30)
45+
return 360 * (end.year - start.year) + 30 * (end.month - start.month) + d2 - d1
46+
47+
48+
def _thirty_360(start: date, end: date) -> float:
49+
d1 = min(start.day, 30)
50+
d2 = min(end.day, 30) if d1 == 30 else end.day
51+
return 360 * (end.year - start.year) + 30 * (end.month - start.month) + d2 - d1
52+
53+
54+
def _act_act_years(dt: date) -> float:
55+
y = dt.year
56+
days_in_year = 365 if y % 4 else 366
57+
dd = date_diff(dt, date(y, 1, 1)).total_seconds() / 86400
58+
return y + dd / days_in_year

ccy/tradingcentres/__init__.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,47 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass, field
43
from datetime import date, timedelta
4+
from typing_extensions import Annotated, Doc
5+
56
import holidays
67
import holidays.countries
78
import holidays.financial
9+
from pydantic import BaseModel, Field
810

911
isoweekend = frozenset((6, 7))
1012
oneday = timedelta(days=1)
1113

1214
trading_centres: dict[str, TradingCentre] = {}
1315

1416

15-
def prevbizday(dte: date, nd: int = 1, tcs: str | None = None) -> date:
17+
def prevbizday(
18+
dte: Annotated[date, Doc("The reference date")],
19+
nd: Annotated[int, Doc("Number of business days to move back")] = 1,
20+
tcs: Annotated[str | None, Doc("Comma-separated trading centre codes")] = None,
21+
) -> date:
22+
"""Return the date nd business days before dte."""
1623
return centres(tcs).prevbizday(dte, nd)
1724

1825

19-
def nextbizday(dte: date, nd: int = 1, tcs: str | None = None) -> date:
26+
def nextbizday(
27+
dte: Annotated[date, Doc("The reference date")],
28+
nd: Annotated[
29+
int,
30+
Doc("Number of business days to move forward; 0 adjusts to next biz day"),
31+
] = 1,
32+
tcs: Annotated[str | None, Doc("Comma-separated trading centre codes")] = None,
33+
) -> date:
34+
"""Return the date nd business days after dte."""
2035
return centres(tcs).nextbizday(dte, nd)
2136

2237

23-
def centres(codes: str | None = None) -> TradingCentres:
38+
def centres(
39+
codes: Annotated[
40+
str | None, Doc("Comma-separated trading centre codes, e.g. 'LON,NY'")
41+
] = None,
42+
) -> TradingCentres:
43+
"""Return a [TradingCentres][ccy.tradingcentres.TradingCentres] instance
44+
for the given centre codes."""
2445
tcs = TradingCentres()
2546
if codes:
2647
lcs = codes.upper().replace(" ", "").split(",")
@@ -31,32 +52,44 @@ def centres(codes: str | None = None) -> TradingCentres:
3152
return tcs
3253

3354

34-
@dataclass
35-
class TradingCentre:
36-
code: str
37-
calendar: holidays.HolidayBase
55+
class TradingCentre(BaseModel, arbitrary_types_allowed=True):
56+
code: str = Field(description="The code of the trading centre")
57+
calendar: holidays.HolidayBase = Field(
58+
exclude=True,
59+
description="The holiday calendar of the trading centre",
60+
)
3861

39-
def isholiday(self, dte: date) -> bool:
62+
def isholiday(self, dte: Annotated[date, Doc("The date to check")]) -> bool:
63+
"""Return True if the date is a holiday."""
4064
return dte in self.calendar
4165

4266

43-
@dataclass
44-
class TradingCentres:
45-
centres: dict[str, TradingCentre] = field(default_factory=dict)
67+
class TradingCentres(BaseModel):
68+
centres: dict[str, TradingCentre] = Field(default_factory=dict)
4669

4770
@property
4871
def code(self) -> str:
72+
"""Comma-separated sorted codes of the trading centres."""
4973
return ",".join(sorted(self.centres))
5074

51-
def isbizday(self, dte: date) -> bool:
75+
def isbizday(self, dte: Annotated[date, Doc("The date to check")]) -> bool:
76+
"""Return True if the date is a business day across all centres."""
5277
if dte.isoweekday() in isoweekend:
5378
return False
5479
for c in self.centres.values():
5580
if c.isholiday(dte):
5681
return False
5782
return True
5883

59-
def nextbizday(self, dte: date, nd: int = 1) -> date:
84+
def nextbizday(
85+
self,
86+
dte: Annotated[date, Doc("The reference date")],
87+
nd: Annotated[
88+
int,
89+
Doc("Number of business days to move forward; 0 adjusts to next biz day"),
90+
] = 1,
91+
) -> date:
92+
"""Return the date nd business days after dte."""
6093
n = 0
6194
while not self.isbizday(dte):
6295
dte += oneday
@@ -66,7 +99,12 @@ def nextbizday(self, dte: date, nd: int = 1) -> date:
6699
n += 1
67100
return dte
68101

69-
def prevbizday(self, dte: date, nd: int = 1) -> date:
102+
def prevbizday(
103+
self,
104+
dte: Annotated[date, Doc("The reference date")],
105+
nd: Annotated[int, Doc("Number of business days to move back")] = 1,
106+
) -> date:
107+
"""Return the date nd business days before dte."""
70108
n = 0
71109
if nd < 0:
72110
return self.nextbizday(dte, -nd)

docs/daycounters.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Day Counters
2+
3+
The `DayCounter` enum provides standard day count conventions used in financial calculations.
4+
5+
## Available conventions
6+
7+
| Value | Description |
8+
|-------|-------------|
9+
| `ACT/360` | Actual days over 360 |
10+
| `ACT/365` | Actual days over 365 |
11+
| `30/360` | 30-day months over 360 (US/NASD convention) |
12+
| `30E/360` | 30-day months over 360 (European convention — end date always capped at 30) |
13+
| `ACT/ACT` | Actual days over actual days in the year |
14+
15+
## Usage
16+
17+
```python
18+
from ccy import DayCounter
19+
from datetime import date
20+
21+
start = date(2024, 1, 1)
22+
end = date(2024, 7, 1)
23+
24+
dc = DayCounter.ACT360
25+
print(dc.count(start, end)) # 182.0
26+
print(dc.dcf(start, end)) # 0.5055...
27+
```
28+
29+
Instantiate from its string value:
30+
31+
```python
32+
dc = DayCounter("ACT/365")
33+
print(dc.dcf(start, end)) # 0.4986...
34+
```
35+
36+
Iterate over all conventions:
37+
38+
```python
39+
for dc in DayCounter:
40+
print(dc.value, dc.dcf(start, end))
41+
```
42+
43+
## Methods
44+
45+
### `count(start, end) -> float`
46+
47+
Returns the number of days between two dates. Accepts both `date` and `datetime` objects.
48+
49+
```python
50+
from datetime import datetime, timezone
51+
52+
start = datetime(2024, 1, 1, 9, 0, tzinfo=timezone.utc)
53+
end = datetime(2024, 1, 2, 15, 0, tzinfo=timezone.utc)
54+
55+
DayCounter.ACT360.count(start, end) # 1.25
56+
```
57+
58+
### `dcf(start, end) -> float`
59+
60+
Returns the day count fraction — the period length expressed as a fraction of a year, according to the convention.
61+
62+
```python
63+
start = date(2024, 1, 1)
64+
end = date(2025, 1, 1)
65+
66+
DayCounter.ACT365.dcf(start, end) # 1.0027... (366 days / 365)
67+
DayCounter.ACT360.dcf(start, end) # 1.0166... (366 days / 360)
68+
DayCounter.ACTACT.dcf(start, end) # 1.0 (spans exactly one year)
69+
DayCounter.THIRTY360.dcf(start, end) # 360.0 (30/360 raw result)
70+
```

docs/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ eur = ccy.currency("aud")
2323
eur.printinfo()
2424
```
2525

26-
a currency object has the following properties:
26+
A currency object has the following properties:
27+
2728
* *code*: the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) three letters code.
2829
* *twoletterscode*: two letter code.
2930
* *default_country*: the default [ISO 3166-1 alpha_2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code for the currency.

0 commit comments

Comments
 (0)