Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ applyTo: '/**'
* The documentation for ccy is available at `https://ccy.quantmid.com`
* Documentation is built using [mkdocs](https://www.mkdocs.org/) and stored in the `docs/` directory. The documentation source files are written in markdown format.
* Do not use em dashes (—) in documentation files or docstrings. Use colons, parentheses, or restructure the sentence instead.
* 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]`)
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ docs: ## Build mkdocs site

.PHONY: docs-serve
docs-serve: ## Serve docs locally with live reload
uv run mkdocs serve
uv run mkdocs serve --livereload --watch ccy --watch docs

.PHONY: publish-docs
publish-docs: ## Publish docs to github pages
Expand Down
5 changes: 2 additions & 3 deletions ccy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
currencydb,
dump_currency_table,
)
from .core.daycounter import alldc, getdc
from .core.daycounter import DayCounter
from .dates.converters import (
date2juldate,
date2timestamp,
Expand All @@ -42,8 +42,7 @@
"currency_pair",
"dump_currency_table",
#
"getdc",
"alldc",
"DayCounter",
#
"country",
"countryccy",
Expand Down
119 changes: 48 additions & 71 deletions ccy/core/daycounter.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,58 @@
"""Day Counter for Counting time between 2 dates.
Implemented::

* Actual 360
* Actual 365
* 30 / 360
* Actual Actual
"""

from __future__ import annotations

from copy import copy
from datetime import date
from typing import Any
from ..dates.utils import date_diff

__all__ = ["getdc", "DayCounter", "alldc"]


def getdc(name: str) -> DayCounter:
return _day_counters[name]()


def alldc() -> dict[str, DayCounterMeta]:
global _day_counters
return copy(_day_counters)


class DayCounterMeta(type):
def __new__(cls, name: str, bases: Any, attrs: Any) -> DayCounterMeta:
new_class = super(DayCounterMeta, cls).__new__(cls, name, bases, attrs)
if name := getattr(new_class, "name", ""):
_day_counters[name] = new_class
return new_class


_day_counters: dict[str, DayCounterMeta] = {}


class DayCounter(metaclass=DayCounterMeta):
name: str = ""

def count(self, start: date, end: date) -> float:
"""Count the number of days between 2 dates"""
return date_diff(end, start).total_seconds() / 86400

def dcf(self, start: date, end: date) -> float:
return self.count(start, end) / 360.0


class Act360(DayCounter):
name = "ACT/360"


class Act365(DayCounter):
name = "ACT/365"

def dcf(self, start: date, end: date) -> float:
return self.count(start, end) / 365.0
from enum import StrEnum

from ..dates.utils import date_diff

class Thirty360(DayCounter):
name = "30/360"
__all__ = ["DayCounter"]

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

class DayCounter(StrEnum):
"""Day count convention types"""

class ActAct(DayCounter):
name = "ACT/ACT"
ACT360 = "ACT/360"
ACT365 = "ACT/365"
THIRTY360 = "30/360"
THIRTYE360 = "30E/360"
ACTACT = "ACT/ACT"

def dcf(self, start: date, end: date) -> float:
return self.act_act_years(end) - self.act_act_years(start)

def act_act_years(self, dt: date) -> float:
y = dt.year
days_in_year = 365 if y % 4 else 366
dd = date_diff(dt, date(y, 1, 1)).total_seconds() / 86400
return y + dd / days_in_year
"""Day count fraction between 2 dates"""
match self:
case DayCounter.ACT360:
return count_days(start, end) / 360.0
case DayCounter.ACT365:
return count_days(start, end) / 365.0
case DayCounter.THIRTY360:
return _thirty_360(start, end)
case DayCounter.THIRTYE360:
return _thirty_e360(start, end)
case DayCounter.ACTACT:
return _act_act_years(end) - _act_act_years(start)
case _:
raise ValueError(f"Unknown day counter: {self}")


def count_days(start: date, end: date) -> float:
"""Count the number of days between 2 dates"""
return date_diff(end, start).total_seconds() / 86400


def _thirty_e360(start: date, end: date) -> float:
d1 = min(start.day, 30)
d2 = min(end.day, 30)
return 360 * (end.year - start.year) + 30 * (end.month - start.month) + d2 - d1


def _thirty_360(start: date, end: date) -> float:
d1 = min(start.day, 30)
d2 = min(end.day, 30) if d1 == 30 else end.day
return 360 * (end.year - start.year) + 30 * (end.month - start.month) + d2 - d1


def _act_act_years(dt: date) -> float:
y = dt.year
days_in_year = 365 if y % 4 else 366
dd = date_diff(dt, date(y, 1, 1)).total_seconds() / 86400
return y + dd / days_in_year
68 changes: 53 additions & 15 deletions ccy/tradingcentres/__init__.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date, timedelta
from typing_extensions import Annotated, Doc

import holidays
import holidays.countries
import holidays.financial
from pydantic import BaseModel, Field

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

trading_centres: dict[str, TradingCentre] = {}


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


def nextbizday(dte: date, nd: int = 1, tcs: str | None = None) -> date:
def nextbizday(
dte: Annotated[date, Doc("The reference date")],
nd: Annotated[
int,
Doc("Number of business days to move forward; 0 adjusts to next biz day"),
] = 1,
tcs: Annotated[str | None, Doc("Comma-separated trading centre codes")] = None,
) -> date:
"""Return the date nd business days after dte."""
return centres(tcs).nextbizday(dte, nd)


def centres(codes: str | None = None) -> TradingCentres:
def centres(
codes: Annotated[
str | None, Doc("Comma-separated trading centre codes, e.g. 'LON,NY'")
] = None,
) -> TradingCentres:
"""Return a [TradingCentres][ccy.tradingcentres.TradingCentres] instance
for the given centre codes."""
tcs = TradingCentres()
if codes:
lcs = codes.upper().replace(" ", "").split(",")
Expand All @@ -31,32 +52,44 @@ def centres(codes: str | None = None) -> TradingCentres:
return tcs


@dataclass
class TradingCentre:
code: str
calendar: holidays.HolidayBase
class TradingCentre(BaseModel, arbitrary_types_allowed=True):
code: str = Field(description="The code of the trading centre")
calendar: holidays.HolidayBase = Field(
exclude=True,
description="The holiday calendar of the trading centre",
)

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


@dataclass
class TradingCentres:
centres: dict[str, TradingCentre] = field(default_factory=dict)
class TradingCentres(BaseModel):
centres: dict[str, TradingCentre] = Field(default_factory=dict)

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

def isbizday(self, dte: date) -> bool:
def isbizday(self, dte: Annotated[date, Doc("The date to check")]) -> bool:
"""Return True if the date is a business day across all centres."""
if dte.isoweekday() in isoweekend:
return False
for c in self.centres.values():
if c.isholiday(dte):
return False
return True

def nextbizday(self, dte: date, nd: int = 1) -> date:
def nextbizday(
self,
dte: Annotated[date, Doc("The reference date")],
nd: Annotated[
int,
Doc("Number of business days to move forward; 0 adjusts to next biz day"),
] = 1,
) -> date:
"""Return the date nd business days after dte."""
n = 0
while not self.isbizday(dte):
dte += oneday
Expand All @@ -66,7 +99,12 @@ def nextbizday(self, dte: date, nd: int = 1) -> date:
n += 1
return dte

def prevbizday(self, dte: date, nd: int = 1) -> date:
def prevbizday(
self,
dte: Annotated[date, Doc("The reference date")],
nd: Annotated[int, Doc("Number of business days to move back")] = 1,
) -> date:
"""Return the date nd business days before dte."""
n = 0
if nd < 0:
return self.nextbizday(dte, -nd)
Expand Down
70 changes: 70 additions & 0 deletions docs/daycounters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Day Counters

The `DayCounter` enum provides standard day count conventions used in financial calculations.

## Available conventions

| Value | Description |
|-------|-------------|
| `ACT/360` | Actual days over 360 |
| `ACT/365` | Actual days over 365 |
| `30/360` | 30-day months over 360 (US/NASD convention) |
| `30E/360` | 30-day months over 360 (European convention — end date always capped at 30) |
| `ACT/ACT` | Actual days over actual days in the year |

## Usage

```python
from ccy import DayCounter
from datetime import date

start = date(2024, 1, 1)
end = date(2024, 7, 1)

dc = DayCounter.ACT360
print(dc.count(start, end)) # 182.0
print(dc.dcf(start, end)) # 0.5055...
```

Instantiate from its string value:

```python
dc = DayCounter("ACT/365")
print(dc.dcf(start, end)) # 0.4986...
```

Iterate over all conventions:

```python
for dc in DayCounter:
print(dc.value, dc.dcf(start, end))
```

## Methods

### `count(start, end) -> float`

Returns the number of days between two dates. Accepts both `date` and `datetime` objects.

```python
from datetime import datetime, timezone

start = datetime(2024, 1, 1, 9, 0, tzinfo=timezone.utc)
end = datetime(2024, 1, 2, 15, 0, tzinfo=timezone.utc)

DayCounter.ACT360.count(start, end) # 1.25
```

### `dcf(start, end) -> float`

Returns the day count fraction — the period length expressed as a fraction of a year, according to the convention.

```python
start = date(2024, 1, 1)
end = date(2025, 1, 1)

DayCounter.ACT365.dcf(start, end) # 1.0027... (366 days / 365)
DayCounter.ACT360.dcf(start, end) # 1.0166... (366 days / 360)
DayCounter.ACTACT.dcf(start, end) # 1.0 (spans exactly one year)
DayCounter.THIRTY360.dcf(start, end) # 360.0 (30/360 raw result)
```
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ eur = ccy.currency("aud")
eur.printinfo()
```

a currency object has the following properties:
A currency object has the following properties:

* *code*: the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) three letters code.
* *twoletterscode*: two letter code.
* *default_country*: the default [ISO 3166-1 alpha_2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code for the currency.
Expand Down
Loading
Loading