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.
@@ -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.
@@ -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)