Skip to content

Commit 8d5e0a4

Browse files
Refactored changes
1 parent 889f599 commit 8d5e0a4

28 files changed

+1097
-297
lines changed

.coverage

52 KB
Binary file not shown.

.coveragerc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# .coveragerc
2+
[run]
3+
source = curo
4+
omit =
5+
*/__init__.py
6+
examples/*
7+
tests/*
8+
*/tests/*
9+
*/test_*.py
10+
11+
[report]
12+
show_missing = true
13+
precision = 2
14+
15+
[html]
16+
directory = coverage_html

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Python-generated files
22
__pycache__/
33
*.py[oc]
4+
.pytest_cache/
45
build/
6+
coverage_html/
57
dist/
68
wheels/
79
*.egg-info

Makefile

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11

2+
PYTHON = python3
3+
# Run doctest on specified files
4+
DOCTEST_FILES = curo/daycount/day_count_factor.py
25

6+
# Run all tests
7+
test: test-pytest test-doctest
8+
9+
test-pytest:
10+
uv run pytest tests/ -v -s
11+
12+
test-doctest:
13+
$(PYTHON) -m doctest $(DOCTEST_FILES) -v
14+
15+
coverage:
16+
$(PYTHON) -m pytest --cov=curo --cov-report=term-missing --cov-report=html tests/ -v
17+
18+
# Clean up (optional)
19+
clean:
20+
rm -rf __pycache__ *.pyc
321

422
# Rebuild the virtual environment if corrupted or outdated
523
# [`uv pip install -e .` installs curo in editable mode]
6-
rebuild-venv:
24+
rebuild:
725
rm -rf .venv
826
rm uv.lock
927
uv venv
1028
uv pip install .
1129
uv pip install -e .
1230
uv lock
1331

14-
15-
all-tests:
16-
uv run pytest tests/ -v -s
32+
.PHONY: test test-pytest test-doctest clean

curo/daycount/__init__.py

Whitespace-only changes.

curo/daycount/actual_360.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Implements the Actual/360 day count convention.
3+
"""
4+
5+
import pandas as pd
6+
from .convention import Convention
7+
from .day_count_factor import DayCountFactor
8+
9+
class Actual360(Convention):
10+
"""
11+
Implements the Actual/360 day count convention.
12+
13+
Counts the actual number of days between two dates, excluding the end date, and
14+
divides by 360 to compute the year fraction. Suitable for compound interest
15+
calculations and XIRR when `use_xirr_method` is True.
16+
17+
Args:
18+
use_post_dates: If True, uses cash flow post dates for day counts; if False, uses
19+
value dates. Defaults to True.
20+
include_non_financing_flows: If True, includes non-financing cash flows (e.g., fees)
21+
in factor computations; if False, excludes them. Defaults to False.
22+
use_xirr_method: If True, uses the XIRR method, setting day count origin to
23+
DRAWDOWN; if False, uses NEIGHBOUR. Defaults to False.
24+
25+
Example:
26+
>>> dc = Actual360()
27+
>>> factor = dc.compute_factor(
28+
... pd.Timestamp('2020-01-28', tz='UTC'),
29+
... pd.Timestamp('2020-02-28', tz='UTC')
30+
... )
31+
>>> print(factor)
32+
31/360 = 0.08611111
33+
"""
34+
def __init__(
35+
self,
36+
use_post_dates: bool = True,
37+
include_non_financing_flows: bool = False,
38+
use_xirr_method: bool = False
39+
):
40+
super().__init__(
41+
use_post_dates=use_post_dates,
42+
include_non_financing_flows=include_non_financing_flows,
43+
use_xirr_method=use_xirr_method
44+
)
45+
46+
def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFactor:
47+
"""
48+
Computes the year fraction between two dates using Actual/360.
49+
50+
Args:
51+
start: The earlier date (pd.Timestamp).
52+
end: The later date (pd.Timestamp).
53+
54+
Returns:
55+
DayCountFactor: The year fraction (days / 360) with operand log.
56+
57+
Raises:
58+
ValueError: If `end` is before `start`.
59+
60+
Example:
61+
>>> dc = Actual360()
62+
>>> factor = dc.compute_factor(
63+
... pd.Timestamp('2020-01-28', tz='UTC'),
64+
... pd.Timestamp('2020-02-28', tz='UTC')
65+
... )
66+
>>> factor.year_fraction
67+
0.08611111111111111
68+
>>> factor.year_fraction_operands
69+
['31/360']
70+
"""
71+
if end < start:
72+
raise ValueError("end must be after start")
73+
days = (end - start).days if end > start else 0 # Exclude end date, 0 for same day
74+
factor = days / 360
75+
return DayCountFactor(
76+
year_fraction=factor,
77+
year_fraction_operands=[f"{days}/360"]
78+
)

curo/daycount/actual_365.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Implements the Actual/365 Fixed day count convention.
3+
"""
4+
5+
from .convention import Convention
6+
from .day_count_factor import DayCountFactor
7+
import pandas as pd
8+
9+
class Actual365(Convention):
10+
"""
11+
Implements the Actual/365 Fixed day count convention.
12+
13+
Counts the actual number of days between two dates, excluding the end date, and
14+
divides by 365 to compute the year fraction. Suitable for compound interest
15+
calculations and XIRR when `use_xirr_method` is True.
16+
17+
Args:
18+
use_post_dates: If True, uses cash flow post dates for day counts; if False, uses
19+
value dates. Defaults to True.
20+
include_non_financing_flows: If True, includes non-financing cash flows (e.g., fees)
21+
in factor computations; if False, excludes them. Defaults to False.
22+
use_xirr_method: If True, uses the XIRR method, setting day count origin to
23+
DRAWDOWN; if False, uses NEIGHBOUR. Defaults to False.
24+
25+
Example:
26+
>>> dc = Actual365()
27+
>>> factor = dc.compute_factor(
28+
... pd.Timestamp('2020-01-28', tz='UTC'),
29+
... pd.Timestamp('2020-02-28', tz='UTC')
30+
... )
31+
>>> print(factor)
32+
31/365 = 0.08493151
33+
"""
34+
def __init__(
35+
self,
36+
use_post_dates: bool = True,
37+
include_non_financing_flows: bool = False,
38+
use_xirr_method: bool = False
39+
):
40+
super().__init__(
41+
use_post_dates=use_post_dates,
42+
include_non_financing_flows=include_non_financing_flows,
43+
use_xirr_method=use_xirr_method
44+
)
45+
46+
def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFactor:
47+
"""
48+
Computes the year fraction between two dates using Actual/365 Fixed.
49+
50+
Args:
51+
start: The earlier date (pd.Timestamp).
52+
end: The later date (pd.Timestamp).
53+
54+
Returns:
55+
DayCountFactor: The year fraction (days / 365) with operand log.
56+
57+
Raises:
58+
ValueError: If `end` is before `start`.
59+
60+
Example:
61+
>>> dc = Actual365()
62+
>>> factor = dc.compute_factor(
63+
... pd.Timestamp('2020-01-28', tz='UTC'),
64+
... pd.Timestamp('2020-02-28', tz='UTC')
65+
... )
66+
>>> factor.year_fraction
67+
0.08493150684931507
68+
>>> factor.year_fraction_operands
69+
['31/365']
70+
"""
71+
if end < start:
72+
raise ValueError("end must be after start")
73+
days = (end - start).days if end > start else 0 # Exclude end date, 0 for same day
74+
factor = days / 365
75+
return DayCountFactor(
76+
year_fraction=factor,
77+
year_fraction_operands=[f"{days}/365"]
78+
)

curo/daycount/actual_isda.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Implements the Actual/Actual (ISDA) day count convention.
3+
"""
4+
5+
import calendar
6+
import pandas as pd
7+
from .convention import Convention
8+
from .day_count_factor import DayCountFactor
9+
10+
class ActualISDA(Convention):
11+
"""
12+
Implements the Actual/Actual (ISDA) day count convention.
13+
14+
Counts the actual number of days between two dates, including the end date for each
15+
year segment, and divides by 365 (non-leap year) or 366 (leap year). For multi-year
16+
periods, splits the calculation by year, summing fractions for each year. Suitable
17+
for compound interest calculations and XIRR when `use_xirr_method` is True.
18+
19+
Args:
20+
use_post_dates: If True, uses cash flow post dates for day counts; if False, uses
21+
value dates. Defaults to True.
22+
include_non_financing_flows: If True, includes non-financing cash flows (e.g., fees)
23+
in factor computations; if False, excludes them. Defaults to False.
24+
use_xirr_method: If True, uses the XIRR method, setting day count origin to
25+
DRAWDOWN; if False, uses NEIGHBOUR. Defaults to False.
26+
27+
Example:
28+
>>> dc = ActualISDA()
29+
>>> factor = dc.compute_factor(
30+
... pd.Timestamp('2020-01-28', tz='UTC'),
31+
... pd.Timestamp('2020-02-28', tz='UTC')
32+
... )
33+
>>> print(factor)
34+
31/366 = 0.08469945
35+
"""
36+
def __init__(
37+
self,
38+
use_post_dates: bool = True,
39+
include_non_financing_flows: bool = False,
40+
use_xirr_method: bool = False
41+
):
42+
super().__init__(
43+
use_post_dates=use_post_dates,
44+
include_non_financing_flows=include_non_financing_flows,
45+
use_xirr_method=use_xirr_method
46+
)
47+
48+
def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFactor:
49+
"""
50+
Computes the year fraction between two dates using Actual/Actual (ISDA).
51+
52+
Args:
53+
start: The earlier date (pd.Timestamp).
54+
end: The later date (pd.Timestamp).
55+
56+
Returns:
57+
DayCountFactor: The year fraction with operand log.
58+
59+
Raises:
60+
ValueError: If `end` is before `start`.
61+
62+
Example:
63+
>>> dc = ActualISDA()
64+
>>> factor = dc.compute_factor(
65+
... pd.Timestamp('2020-01-28', tz='UTC'),
66+
... pd.Timestamp('2020-02-28', tz='UTC')
67+
... )
68+
>>> factor.year_fraction
69+
0.08469945355191257
70+
>>> factor.year_fraction_operands
71+
['31/366']
72+
"""
73+
if end < start:
74+
raise ValueError("end must be after start")
75+
if end == start:
76+
return DayCountFactor(year_fraction=0.0, year_fraction_operands=["0/365"])
77+
78+
start_year = start.year
79+
end_year = end.year
80+
factor = 0.0
81+
82+
if start_year == end_year:
83+
# Same year: use single denominator (365 or 366)
84+
days = (end - start).days
85+
denominator = 366 if calendar.isleap(start_year) else 365
86+
factor = days / denominator
87+
return DayCountFactor(
88+
year_fraction=factor,
89+
year_fraction_operands=[f"{days}/{denominator}"]
90+
)
91+
92+
# Multi-year: split by year
93+
operand_log = []
94+
current_date = start
95+
current_year = start_year
96+
97+
while current_year != end_year:
98+
year_end = pd.Timestamp(f"{current_year}-12-31", tz="UTC")
99+
days = (year_end - current_date).days + 1 if year_end >= current_date else 0
100+
denominator = 366 if calendar.isleap(current_year) else 365
101+
#if days >= 0: # Skip if start is Dec 31 <- Wrong: need to add 1 day
102+
if days == 0:
103+
days = 1
104+
factor += days / denominator
105+
operand_log.append(f"{days}/{denominator}")
106+
current_date = year_end + pd.Timedelta(days=1) # Move to Jan 1 next year
107+
current_year += 1
108+
109+
# Final partial year
110+
days = (end - current_date).days if end >= current_date else 0
111+
denominator = 366 if calendar.isleap(end_year) else 365
112+
if days > 0:
113+
factor += days / denominator
114+
operand_log.append(f"{days}/{denominator}")
115+
116+
return DayCountFactor(
117+
year_fraction=factor,
118+
year_fraction_operands=operand_log
119+
)

0 commit comments

Comments
 (0)