oq-core 0.1.0__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.
- oq_core-0.1.0/.gitignore +82 -0
- oq_core-0.1.0/PKG-INFO +36 -0
- oq_core-0.1.0/README.md +14 -0
- oq_core-0.1.0/pyproject.toml +33 -0
- oq_core-0.1.0/src/oq_core/__init__.py +14 -0
- oq_core-0.1.0/src/oq_core/calendar.py +192 -0
- oq_core-0.1.0/src/oq_core/instrument.py +92 -0
- oq_core-0.1.0/tests/__init__.py +0 -0
- oq_core-0.1.0/tests/test_calendar.py +126 -0
- oq_core-0.1.0/tests/test_instrument.py +84 -0
oq_core-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
wheels/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
eggs/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
*.manifest
|
|
18
|
+
*.spec
|
|
19
|
+
pip-log.txt
|
|
20
|
+
pip-delete-this-directory.txt
|
|
21
|
+
|
|
22
|
+
# uv
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
env/
|
|
26
|
+
ENV/
|
|
27
|
+
.python-version
|
|
28
|
+
|
|
29
|
+
# Testing / coverage
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
htmlcov/
|
|
34
|
+
.tox/
|
|
35
|
+
.nox/
|
|
36
|
+
coverage.xml
|
|
37
|
+
*.cover
|
|
38
|
+
.cache
|
|
39
|
+
|
|
40
|
+
# mypy / ruff
|
|
41
|
+
.mypy_cache/
|
|
42
|
+
.ruff_cache/
|
|
43
|
+
.dmypy.json
|
|
44
|
+
dmypy.json
|
|
45
|
+
|
|
46
|
+
# Jupyter
|
|
47
|
+
.ipynb_checkpoints/
|
|
48
|
+
*.ipynb_checkpoints
|
|
49
|
+
|
|
50
|
+
# Data / artifacts
|
|
51
|
+
data/
|
|
52
|
+
*.parquet
|
|
53
|
+
*.duckdb
|
|
54
|
+
*.duckdb.wal
|
|
55
|
+
*.csv.gz
|
|
56
|
+
*.zip
|
|
57
|
+
.openquant/
|
|
58
|
+
|
|
59
|
+
!packages/*/tests/fixtures/**
|
|
60
|
+
|
|
61
|
+
# IDE / OS
|
|
62
|
+
.idea/
|
|
63
|
+
.vscode/
|
|
64
|
+
*.swp
|
|
65
|
+
*.swo
|
|
66
|
+
.DS_Store
|
|
67
|
+
Thumbs.db
|
|
68
|
+
|
|
69
|
+
# Logs
|
|
70
|
+
*.log
|
|
71
|
+
logs/
|
|
72
|
+
|
|
73
|
+
# Secrets
|
|
74
|
+
.env
|
|
75
|
+
.env.*
|
|
76
|
+
!.env.example
|
|
77
|
+
*.pem
|
|
78
|
+
*.key
|
|
79
|
+
|
|
80
|
+
# build artifacts
|
|
81
|
+
dist/
|
|
82
|
+
*.egg-info/
|
oq_core-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oq-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared primitives for the OpenQuant India ecosystem: Instrument, TradingCalendar, config.
|
|
5
|
+
Project-URL: Homepage, https://github.com/revorhq/openquant
|
|
6
|
+
Project-URL: Repository, https://github.com/revorhq/openquant
|
|
7
|
+
Project-URL: Issues, https://github.com/revorhq/openquant/issues
|
|
8
|
+
Author: OpenQuant India Contributors
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: backtesting,finance,india,nse,quant,trading
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# oq-core
|
|
24
|
+
|
|
25
|
+
Shared primitives for the OpenQuant India ecosystem.
|
|
26
|
+
|
|
27
|
+
- `Instrument` — typed model for an exchange-listed instrument (symbol, ISIN, exchange, segment, lot size).
|
|
28
|
+
- `TradingCalendar` — NSE trading calendar with holidays, weekends, and muhurat sessions.
|
|
29
|
+
|
|
30
|
+
Install:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install oq-core
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See the [main repository](https://github.com/openquant-india/openquant) for the full project, license, and disclaimers.
|
oq_core-0.1.0/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# oq-core
|
|
2
|
+
|
|
3
|
+
Shared primitives for the OpenQuant India ecosystem.
|
|
4
|
+
|
|
5
|
+
- `Instrument` — typed model for an exchange-listed instrument (symbol, ISIN, exchange, segment, lot size).
|
|
6
|
+
- `TradingCalendar` — NSE trading calendar with holidays, weekends, and muhurat sessions.
|
|
7
|
+
|
|
8
|
+
Install:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install oq-core
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
See the [main repository](https://github.com/openquant-india/openquant) for the full project, license, and disclaimers.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "oq-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared primitives for the OpenQuant India ecosystem: Instrument, TradingCalendar, config."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
authors = [{ name = "OpenQuant India Contributors" }]
|
|
9
|
+
keywords = ["quant", "trading", "india", "nse", "backtesting", "finance"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/revorhq/openquant"
|
|
25
|
+
Repository = "https://github.com/revorhq/openquant"
|
|
26
|
+
Issues = "https://github.com/revorhq/openquant/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/oq_core"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""oq-core: shared primitives for the OpenQuant India ecosystem."""
|
|
2
|
+
|
|
3
|
+
from oq_core.calendar import TradingCalendar
|
|
4
|
+
from oq_core.instrument import Exchange, Instrument, Segment
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Exchange",
|
|
10
|
+
"Instrument",
|
|
11
|
+
"Segment",
|
|
12
|
+
"TradingCalendar",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""NSE trading calendar.
|
|
2
|
+
|
|
3
|
+
This module implements a minimal-but-correct NSE equity trading calendar:
|
|
4
|
+
|
|
5
|
+
* Mon-Fri are trading days.
|
|
6
|
+
* Saturday and Sunday are non-trading days.
|
|
7
|
+
* A curated set of NSE trading holidays is loaded from
|
|
8
|
+
:data:`HOLIDAYS_BY_YEAR`.
|
|
9
|
+
* Muhurat (Diwali) sessions are special trading days where the date would
|
|
10
|
+
otherwise be a holiday or where only an evening session is open.
|
|
11
|
+
|
|
12
|
+
The calendar API is intentionally small and deterministic so that ``oq-data``
|
|
13
|
+
and ``oq-backtest`` can rely on it without pulling pandas at import time.
|
|
14
|
+
|
|
15
|
+
Holiday lists are maintained by year. They are best-effort and should be
|
|
16
|
+
verified against the NSE annual circular for any production usage.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import date, datetime, time, timedelta
|
|
24
|
+
|
|
25
|
+
NSE_OPEN = time(9, 15)
|
|
26
|
+
NSE_CLOSE = time(15, 30)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class MuhuratSession:
|
|
31
|
+
"""A Diwali muhurat trading session (evening, typically ~1 hour)."""
|
|
32
|
+
|
|
33
|
+
session_date: date
|
|
34
|
+
open_time: time
|
|
35
|
+
close_time: time
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# NSE trading holidays. Source: NSE annual holiday circulars.
|
|
39
|
+
# Only includes fully-closed equity trading days (not settlement holidays).
|
|
40
|
+
# This list is best-effort; consumers should verify against the NSE circular.
|
|
41
|
+
HOLIDAYS_BY_YEAR: dict[int, frozenset[date]] = {
|
|
42
|
+
2023: frozenset(
|
|
43
|
+
{
|
|
44
|
+
date(2023, 1, 26), # Republic Day
|
|
45
|
+
date(2023, 3, 7), # Holi
|
|
46
|
+
date(2023, 3, 30), # Ram Navami
|
|
47
|
+
date(2023, 4, 4), # Mahavir Jayanti
|
|
48
|
+
date(2023, 4, 7), # Good Friday
|
|
49
|
+
date(2023, 4, 14), # Dr. Ambedkar Jayanti
|
|
50
|
+
date(2023, 5, 1), # Maharashtra Day
|
|
51
|
+
date(2023, 6, 28), # Bakri Id
|
|
52
|
+
date(2023, 8, 15), # Independence Day
|
|
53
|
+
date(2023, 9, 19), # Ganesh Chaturthi
|
|
54
|
+
date(2023, 10, 2), # Gandhi Jayanti
|
|
55
|
+
date(2023, 10, 24), # Dussehra
|
|
56
|
+
date(2023, 11, 14), # Diwali Balipratipada
|
|
57
|
+
date(2023, 11, 27), # Guru Nanak Jayanti
|
|
58
|
+
date(2023, 12, 25), # Christmas
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
2024: frozenset(
|
|
62
|
+
{
|
|
63
|
+
date(2024, 1, 26), # Republic Day
|
|
64
|
+
date(2024, 3, 8), # Mahashivratri
|
|
65
|
+
date(2024, 3, 25), # Holi
|
|
66
|
+
date(2024, 3, 29), # Good Friday
|
|
67
|
+
date(2024, 4, 11), # Id-Ul-Fitr
|
|
68
|
+
date(2024, 4, 17), # Ram Navami
|
|
69
|
+
date(2024, 5, 1), # Maharashtra Day
|
|
70
|
+
date(2024, 5, 20), # General Elections (Mumbai)
|
|
71
|
+
date(2024, 6, 17), # Bakri Id
|
|
72
|
+
date(2024, 7, 17), # Muharram
|
|
73
|
+
date(2024, 8, 15), # Independence Day
|
|
74
|
+
date(2024, 10, 2), # Gandhi Jayanti
|
|
75
|
+
date(2024, 11, 1), # Diwali Laxmi Pujan (full holiday; muhurat session in evening)
|
|
76
|
+
date(2024, 11, 15), # Guru Nanak Jayanti
|
|
77
|
+
date(2024, 12, 25), # Christmas
|
|
78
|
+
}
|
|
79
|
+
),
|
|
80
|
+
2025: frozenset(
|
|
81
|
+
{
|
|
82
|
+
date(2025, 2, 26), # Mahashivratri
|
|
83
|
+
date(2025, 3, 14), # Holi
|
|
84
|
+
date(2025, 3, 31), # Id-Ul-Fitr
|
|
85
|
+
date(2025, 4, 10), # Mahavir Jayanti
|
|
86
|
+
date(2025, 4, 14), # Dr. Ambedkar Jayanti
|
|
87
|
+
date(2025, 4, 18), # Good Friday
|
|
88
|
+
date(2025, 5, 1), # Maharashtra Day
|
|
89
|
+
date(2025, 8, 15), # Independence Day
|
|
90
|
+
date(2025, 8, 27), # Ganesh Chaturthi
|
|
91
|
+
date(2025, 10, 2), # Gandhi Jayanti / Dussehra
|
|
92
|
+
date(2025, 10, 21), # Diwali Laxmi Pujan (muhurat session in evening)
|
|
93
|
+
date(2025, 10, 22), # Diwali Balipratipada
|
|
94
|
+
date(2025, 11, 5), # Guru Nanak Jayanti
|
|
95
|
+
date(2025, 12, 25), # Christmas
|
|
96
|
+
}
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Muhurat (Diwali) trading sessions. These are evening sessions on what would
|
|
102
|
+
# otherwise be a non-trading day.
|
|
103
|
+
MUHURAT_SESSIONS: tuple[MuhuratSession, ...] = (
|
|
104
|
+
MuhuratSession(date(2023, 11, 12), time(18, 15), time(19, 15)),
|
|
105
|
+
MuhuratSession(date(2024, 11, 1), time(18, 0), time(19, 0)),
|
|
106
|
+
MuhuratSession(date(2025, 10, 21), time(13, 45), time(14, 45)),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TradingCalendar:
|
|
111
|
+
"""NSE equity trading calendar.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
holidays:
|
|
116
|
+
Optional override mapping of year to holiday set. Defaults to the
|
|
117
|
+
bundled :data:`HOLIDAYS_BY_YEAR`.
|
|
118
|
+
muhurat:
|
|
119
|
+
Optional override of muhurat sessions. Defaults to
|
|
120
|
+
:data:`MUHURAT_SESSIONS`.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
holidays: dict[int, frozenset[date]] | None = None,
|
|
126
|
+
muhurat: tuple[MuhuratSession, ...] | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._holidays: dict[int, frozenset[date]] = (
|
|
129
|
+
dict(holidays) if holidays is not None else dict(HOLIDAYS_BY_YEAR)
|
|
130
|
+
)
|
|
131
|
+
self._muhurat: dict[date, MuhuratSession] = {
|
|
132
|
+
session.session_date: session
|
|
133
|
+
for session in (muhurat if muhurat is not None else MUHURAT_SESSIONS)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def is_weekend(self, day: date) -> bool:
|
|
137
|
+
return day.weekday() >= 5
|
|
138
|
+
|
|
139
|
+
def is_holiday(self, day: date) -> bool:
|
|
140
|
+
"""True if ``day`` is on the published NSE holiday list."""
|
|
141
|
+
return day in self._holidays.get(day.year, frozenset())
|
|
142
|
+
|
|
143
|
+
def muhurat_session(self, day: date) -> MuhuratSession | None:
|
|
144
|
+
"""Return the muhurat session for ``day``, if any."""
|
|
145
|
+
return self._muhurat.get(day)
|
|
146
|
+
|
|
147
|
+
def is_session(self, day: date) -> bool:
|
|
148
|
+
"""True if regular trading happens on ``day`` (excludes muhurat-only days)."""
|
|
149
|
+
if self.is_weekend(day):
|
|
150
|
+
return False
|
|
151
|
+
return not self.is_holiday(day)
|
|
152
|
+
|
|
153
|
+
def is_trading_day(self, day: date) -> bool:
|
|
154
|
+
"""True if any trading occurs on ``day`` (regular session OR muhurat)."""
|
|
155
|
+
if self.is_session(day):
|
|
156
|
+
return True
|
|
157
|
+
return day in self._muhurat
|
|
158
|
+
|
|
159
|
+
def next_session(self, day: date) -> date:
|
|
160
|
+
"""Smallest ``d > day`` that is a regular session."""
|
|
161
|
+
candidate = day + timedelta(days=1)
|
|
162
|
+
while not self.is_session(candidate):
|
|
163
|
+
candidate += timedelta(days=1)
|
|
164
|
+
return candidate
|
|
165
|
+
|
|
166
|
+
def previous_session(self, day: date) -> date:
|
|
167
|
+
"""Largest ``d < day`` that is a regular session."""
|
|
168
|
+
candidate = day - timedelta(days=1)
|
|
169
|
+
while not self.is_session(candidate):
|
|
170
|
+
candidate -= timedelta(days=1)
|
|
171
|
+
return candidate
|
|
172
|
+
|
|
173
|
+
def sessions(self, start: date, end: date) -> Iterator[date]:
|
|
174
|
+
"""Yield regular trading sessions in ``[start, end]`` inclusive."""
|
|
175
|
+
if end < start:
|
|
176
|
+
return
|
|
177
|
+
current = start
|
|
178
|
+
while current <= end:
|
|
179
|
+
if self.is_session(current):
|
|
180
|
+
yield current
|
|
181
|
+
current += timedelta(days=1)
|
|
182
|
+
|
|
183
|
+
def session_count(self, start: date, end: date) -> int:
|
|
184
|
+
"""Number of regular trading sessions in ``[start, end]`` inclusive."""
|
|
185
|
+
return sum(1 for _ in self.sessions(start, end))
|
|
186
|
+
|
|
187
|
+
def is_market_open(self, when: datetime) -> bool:
|
|
188
|
+
"""True if the regular cash market is open at ``when`` (naive local IST)."""
|
|
189
|
+
day = when.date()
|
|
190
|
+
if not self.is_session(day):
|
|
191
|
+
return False
|
|
192
|
+
return NSE_OPEN <= when.time() <= NSE_CLOSE
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Instrument model: a typed representation of an exchange-listed instrument."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
_ISIN_PATTERN = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}\d$")
|
|
10
|
+
_SYMBOL_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9&\-\.]{0,49}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Exchange(StrEnum):
|
|
14
|
+
"""Indian exchanges supported by OpenQuant."""
|
|
15
|
+
|
|
16
|
+
NSE = "NSE"
|
|
17
|
+
BSE = "BSE"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Segment(StrEnum):
|
|
21
|
+
"""Market segments within an exchange."""
|
|
22
|
+
|
|
23
|
+
EQ = "EQ"
|
|
24
|
+
FUT = "FUT"
|
|
25
|
+
OPT = "OPT"
|
|
26
|
+
CDS = "CDS"
|
|
27
|
+
COM = "COM"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Instrument:
|
|
32
|
+
"""A single exchange-listed instrument.
|
|
33
|
+
|
|
34
|
+
Identity is ``(exchange, segment, symbol)``. ``isin`` is the canonical
|
|
35
|
+
cross-exchange identifier used to follow corporate actions and renames
|
|
36
|
+
(e.g. the HDFC/HDFC Bank merger).
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
symbol:
|
|
41
|
+
Trading symbol as listed on the exchange (uppercase). For NSE EQ this
|
|
42
|
+
is the tradingsymbol (e.g. ``"RELIANCE"``).
|
|
43
|
+
isin:
|
|
44
|
+
12-character ISIN (e.g. ``"INE002A01018"``). Required for equities;
|
|
45
|
+
optional for derivatives.
|
|
46
|
+
exchange:
|
|
47
|
+
Listing exchange.
|
|
48
|
+
segment:
|
|
49
|
+
Market segment (EQ, FUT, OPT, CDS, COM).
|
|
50
|
+
lot_size:
|
|
51
|
+
Minimum tradable quantity. ``1`` for cash equities, varies for F&O.
|
|
52
|
+
tick_size:
|
|
53
|
+
Minimum price increment in INR. Defaults to ``0.05``.
|
|
54
|
+
name:
|
|
55
|
+
Human-readable company / contract name.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
symbol: str
|
|
59
|
+
exchange: Exchange = Exchange.NSE
|
|
60
|
+
segment: Segment = Segment.EQ
|
|
61
|
+
isin: str | None = None
|
|
62
|
+
lot_size: int = 1
|
|
63
|
+
tick_size: float = 0.05
|
|
64
|
+
name: str | None = None
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
if not isinstance(self.symbol, str) or not self.symbol:
|
|
68
|
+
raise ValueError("symbol must be a non-empty string")
|
|
69
|
+
if not _SYMBOL_PATTERN.match(self.symbol):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"invalid symbol {self.symbol!r}: must be uppercase alphanumeric "
|
|
72
|
+
"(plus & - .), up to 50 chars"
|
|
73
|
+
)
|
|
74
|
+
if self.isin is not None and not _ISIN_PATTERN.match(self.isin):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"invalid ISIN {self.isin!r}: expected 12 chars matching "
|
|
77
|
+
"country(2) + alphanumeric(9) + checksum(1)"
|
|
78
|
+
)
|
|
79
|
+
if self.segment is Segment.EQ and self.isin is None:
|
|
80
|
+
raise ValueError("equity instruments require an ISIN")
|
|
81
|
+
if self.lot_size < 1:
|
|
82
|
+
raise ValueError(f"lot_size must be >= 1, got {self.lot_size}")
|
|
83
|
+
if self.tick_size <= 0:
|
|
84
|
+
raise ValueError(f"tick_size must be > 0, got {self.tick_size}")
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def key(self) -> tuple[str, str, str]:
|
|
88
|
+
"""Stable identity tuple suitable for dict keys."""
|
|
89
|
+
return (self.exchange.value, self.segment.value, self.symbol)
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
return f"{self.exchange.value}:{self.segment.value}:{self.symbol}"
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Tests for oq_core.calendar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from oq_core import TradingCalendar
|
|
9
|
+
from oq_core.calendar import MUHURAT_SESSIONS, MuhuratSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def cal() -> TradingCalendar:
|
|
14
|
+
return TradingCalendar()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestWeekends:
|
|
18
|
+
def test_saturday_is_weekend(self, cal: TradingCalendar) -> None:
|
|
19
|
+
assert cal.is_weekend(date(2024, 6, 8)) # Saturday
|
|
20
|
+
assert not cal.is_session(date(2024, 6, 8))
|
|
21
|
+
|
|
22
|
+
def test_sunday_is_weekend(self, cal: TradingCalendar) -> None:
|
|
23
|
+
assert cal.is_weekend(date(2024, 6, 9)) # Sunday
|
|
24
|
+
assert not cal.is_session(date(2024, 6, 9))
|
|
25
|
+
|
|
26
|
+
def test_weekday_not_weekend(self, cal: TradingCalendar) -> None:
|
|
27
|
+
assert not cal.is_weekend(date(2024, 6, 10)) # Monday
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestHolidays:
|
|
31
|
+
def test_republic_day_2024(self, cal: TradingCalendar) -> None:
|
|
32
|
+
assert cal.is_holiday(date(2024, 1, 26))
|
|
33
|
+
assert not cal.is_session(date(2024, 1, 26))
|
|
34
|
+
|
|
35
|
+
def test_christmas_2025(self, cal: TradingCalendar) -> None:
|
|
36
|
+
assert cal.is_holiday(date(2025, 12, 25))
|
|
37
|
+
|
|
38
|
+
def test_regular_weekday_not_holiday(self, cal: TradingCalendar) -> None:
|
|
39
|
+
assert not cal.is_holiday(date(2024, 6, 10))
|
|
40
|
+
assert cal.is_session(date(2024, 6, 10))
|
|
41
|
+
|
|
42
|
+
def test_unknown_year_no_holidays(self, cal: TradingCalendar) -> None:
|
|
43
|
+
assert not cal.is_holiday(date(2099, 1, 26))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestMuhurat:
|
|
47
|
+
def test_known_muhurat_dates_present(self) -> None:
|
|
48
|
+
dates = {s.session_date for s in MUHURAT_SESSIONS}
|
|
49
|
+
assert date(2024, 11, 1) in dates
|
|
50
|
+
assert date(2025, 10, 21) in dates
|
|
51
|
+
|
|
52
|
+
def test_muhurat_lookup(self, cal: TradingCalendar) -> None:
|
|
53
|
+
s = cal.muhurat_session(date(2024, 11, 1))
|
|
54
|
+
assert s is not None
|
|
55
|
+
assert s.open_time < s.close_time
|
|
56
|
+
|
|
57
|
+
def test_muhurat_only_day_is_trading_not_session(self) -> None:
|
|
58
|
+
# 2024-11-01 is a Diwali holiday but has a muhurat session
|
|
59
|
+
cal = TradingCalendar()
|
|
60
|
+
d = date(2024, 11, 1)
|
|
61
|
+
assert cal.is_holiday(d)
|
|
62
|
+
assert not cal.is_session(d)
|
|
63
|
+
assert cal.is_trading_day(d)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestNavigation:
|
|
67
|
+
def test_next_session_skips_weekend(self, cal: TradingCalendar) -> None:
|
|
68
|
+
# Friday 2024-06-07 -> next session Monday 2024-06-10
|
|
69
|
+
assert cal.next_session(date(2024, 6, 7)) == date(2024, 6, 10)
|
|
70
|
+
|
|
71
|
+
def test_previous_session_skips_weekend(self, cal: TradingCalendar) -> None:
|
|
72
|
+
# Monday 2024-06-10 -> previous session Friday 2024-06-07
|
|
73
|
+
assert cal.previous_session(date(2024, 6, 10)) == date(2024, 6, 7)
|
|
74
|
+
|
|
75
|
+
def test_next_session_skips_holiday(self, cal: TradingCalendar) -> None:
|
|
76
|
+
# 2024-08-15 (Thu) is Independence Day -> next session 2024-08-16 (Fri)
|
|
77
|
+
assert cal.next_session(date(2024, 8, 14)) == date(2024, 8, 16)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestSessionRange:
|
|
81
|
+
def test_sessions_in_week(self, cal: TradingCalendar) -> None:
|
|
82
|
+
# Mon 2024-06-10 to Sun 2024-06-16: 5 sessions
|
|
83
|
+
days = list(cal.sessions(date(2024, 6, 10), date(2024, 6, 16)))
|
|
84
|
+
assert len(days) == 5
|
|
85
|
+
assert days[0] == date(2024, 6, 10)
|
|
86
|
+
assert days[-1] == date(2024, 6, 14)
|
|
87
|
+
|
|
88
|
+
def test_sessions_empty_when_end_before_start(self, cal: TradingCalendar) -> None:
|
|
89
|
+
assert list(cal.sessions(date(2024, 6, 10), date(2024, 6, 5))) == []
|
|
90
|
+
|
|
91
|
+
def test_session_count_around_holiday(self, cal: TradingCalendar) -> None:
|
|
92
|
+
# Week of Independence Day 2024 (Thu Aug 15): Mon-Fri minus Thu = 4
|
|
93
|
+
assert cal.session_count(date(2024, 8, 12), date(2024, 8, 16)) == 4
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestMarketHours:
|
|
97
|
+
def test_open_at_open_time(self, cal: TradingCalendar) -> None:
|
|
98
|
+
assert cal.is_market_open(datetime(2024, 6, 10, 9, 15))
|
|
99
|
+
|
|
100
|
+
def test_open_mid_session(self, cal: TradingCalendar) -> None:
|
|
101
|
+
assert cal.is_market_open(datetime(2024, 6, 10, 12, 0))
|
|
102
|
+
|
|
103
|
+
def test_closed_before_open(self, cal: TradingCalendar) -> None:
|
|
104
|
+
assert not cal.is_market_open(datetime(2024, 6, 10, 9, 0))
|
|
105
|
+
|
|
106
|
+
def test_closed_after_close(self, cal: TradingCalendar) -> None:
|
|
107
|
+
assert not cal.is_market_open(datetime(2024, 6, 10, 15, 31))
|
|
108
|
+
|
|
109
|
+
def test_closed_on_weekend(self, cal: TradingCalendar) -> None:
|
|
110
|
+
assert not cal.is_market_open(datetime(2024, 6, 8, 12, 0))
|
|
111
|
+
|
|
112
|
+
def test_closed_on_holiday(self, cal: TradingCalendar) -> None:
|
|
113
|
+
assert not cal.is_market_open(datetime(2024, 1, 26, 12, 0))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestOverrides:
|
|
117
|
+
def test_custom_holidays(self) -> None:
|
|
118
|
+
custom = {2030: frozenset({date(2030, 1, 1)})}
|
|
119
|
+
cal = TradingCalendar(holidays=custom)
|
|
120
|
+
assert cal.is_holiday(date(2030, 1, 1))
|
|
121
|
+
assert not cal.is_holiday(date(2024, 1, 26))
|
|
122
|
+
|
|
123
|
+
def test_custom_muhurat(self) -> None:
|
|
124
|
+
s = MuhuratSession(date(2030, 11, 5), time(18, 0), time(19, 0))
|
|
125
|
+
cal = TradingCalendar(muhurat=(s,))
|
|
126
|
+
assert cal.muhurat_session(date(2030, 11, 5)) == s
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tests for oq_core.instrument."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from oq_core import Exchange, Instrument, Segment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestInstrumentValid:
|
|
10
|
+
def test_minimal_equity(self) -> None:
|
|
11
|
+
ins = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
12
|
+
assert ins.symbol == "RELIANCE"
|
|
13
|
+
assert ins.isin == "INE002A01018"
|
|
14
|
+
assert ins.exchange is Exchange.NSE
|
|
15
|
+
assert ins.segment is Segment.EQ
|
|
16
|
+
assert ins.lot_size == 1
|
|
17
|
+
assert ins.tick_size == 0.05
|
|
18
|
+
|
|
19
|
+
def test_symbol_with_special_chars(self) -> None:
|
|
20
|
+
ins = Instrument(symbol="M&M", isin="INE101A01026")
|
|
21
|
+
assert ins.symbol == "M&M"
|
|
22
|
+
|
|
23
|
+
def test_hyphen_and_dot_allowed(self) -> None:
|
|
24
|
+
Instrument(symbol="L&T-FH", isin="INE498L01015")
|
|
25
|
+
Instrument(symbol="NIFTY-25JUN", segment=Segment.FUT, lot_size=75)
|
|
26
|
+
|
|
27
|
+
def test_derivative_no_isin_ok(self) -> None:
|
|
28
|
+
ins = Instrument(
|
|
29
|
+
symbol="NIFTY25JUNFUT",
|
|
30
|
+
segment=Segment.FUT,
|
|
31
|
+
lot_size=75,
|
|
32
|
+
tick_size=0.05,
|
|
33
|
+
)
|
|
34
|
+
assert ins.isin is None
|
|
35
|
+
assert ins.lot_size == 75
|
|
36
|
+
|
|
37
|
+
def test_frozen(self) -> None:
|
|
38
|
+
ins = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
39
|
+
with pytest.raises(AttributeError):
|
|
40
|
+
ins.symbol = "INFY" # type: ignore[misc]
|
|
41
|
+
|
|
42
|
+
def test_key_tuple(self) -> None:
|
|
43
|
+
ins = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
44
|
+
assert ins.key == ("NSE", "EQ", "RELIANCE")
|
|
45
|
+
|
|
46
|
+
def test_str(self) -> None:
|
|
47
|
+
ins = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
48
|
+
assert str(ins) == "NSE:EQ:RELIANCE"
|
|
49
|
+
|
|
50
|
+
def test_hashable(self) -> None:
|
|
51
|
+
a = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
52
|
+
b = Instrument(symbol="RELIANCE", isin="INE002A01018")
|
|
53
|
+
assert hash(a) == hash(b)
|
|
54
|
+
assert {a, b} == {a}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestInstrumentInvalid:
|
|
58
|
+
def test_empty_symbol(self) -> None:
|
|
59
|
+
with pytest.raises(ValueError, match="symbol"):
|
|
60
|
+
Instrument(symbol="", isin="INE002A01018")
|
|
61
|
+
|
|
62
|
+
def test_lowercase_symbol(self) -> None:
|
|
63
|
+
with pytest.raises(ValueError, match="symbol"):
|
|
64
|
+
Instrument(symbol="reliance", isin="INE002A01018")
|
|
65
|
+
|
|
66
|
+
def test_bad_isin_length(self) -> None:
|
|
67
|
+
with pytest.raises(ValueError, match="ISIN"):
|
|
68
|
+
Instrument(symbol="RELIANCE", isin="INE002A0101")
|
|
69
|
+
|
|
70
|
+
def test_bad_isin_country(self) -> None:
|
|
71
|
+
with pytest.raises(ValueError, match="ISIN"):
|
|
72
|
+
Instrument(symbol="RELIANCE", isin="1NE002A01018")
|
|
73
|
+
|
|
74
|
+
def test_equity_requires_isin(self) -> None:
|
|
75
|
+
with pytest.raises(ValueError, match="equity"):
|
|
76
|
+
Instrument(symbol="RELIANCE")
|
|
77
|
+
|
|
78
|
+
def test_zero_lot_size(self) -> None:
|
|
79
|
+
with pytest.raises(ValueError, match="lot_size"):
|
|
80
|
+
Instrument(symbol="RELIANCE", isin="INE002A01018", lot_size=0)
|
|
81
|
+
|
|
82
|
+
def test_negative_tick(self) -> None:
|
|
83
|
+
with pytest.raises(ValueError, match="tick_size"):
|
|
84
|
+
Instrument(symbol="RELIANCE", isin="INE002A01018", tick_size=-0.01)
|