Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## [Unreleased]

### Changed
- **Instrument construction errors** — missing/invalid fields on `Index`, `Equity`,
`Future`, `Option`, etc. now raise `ConfigurationError` with a short usage example
instead of a raw Pydantic `ValidationError`. Compact `__repr__` forms such as
`Index(NSE:NIFTY)` and `Option(NFO:NIFTY 25000 CE 2026-03-27)`.
- Bumped `idna` (3.11 → 3.18), `python-dotenv` (1.2.1 → 1.2.2), `pytest` (9.0.2 → 9.1.1),
and `pygments` (2.19.2 → 2.20.0).

Expand Down
114 changes: 114 additions & 0 deletions tests/unit/test_instrument_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Construction errors and reprs for public instrument types."""

from __future__ import annotations

from datetime import date

import pytest
from pydantic import ValidationError

from tt_connect.enums import Exchange, OptionType
from tt_connect.exceptions import ConfigurationError
from tt_connect.instruments import Commodity, Currency, Equity, Future, Index, Option
from tt_connect.core.models.instruments import Instrument


@pytest.mark.parametrize(
("cls", "required_snippet", "example_snippet"),
[
(Index, "exchange and symbol", 'Index(exchange=Exchange.NSE, symbol="NIFTY")'),
(Equity, "exchange and symbol", 'Equity(exchange=Exchange.NSE, symbol="RELIANCE")'),
(Currency, "exchange and symbol", "Currency(exchange=Exchange.CDS"),
(Commodity, "exchange and symbol", "Commodity(exchange=Exchange.MCX"),
(Future, "exchange, symbol, and expiry", "Future(exchange=Exchange.NFO"),
(
Option,
"exchange, symbol, expiry, strike, and option_type",
"Option(exchange=Exchange.NFO",
),
],
)
def test_missing_fields_raise_configuration_error_with_example(
cls: type,
required_snippet: str,
example_snippet: str,
) -> None:
with pytest.raises(ConfigurationError) as exc_info:
cls()

msg = str(exc_info.value)
assert f"{cls.__name__} requires {required_snippet}" in msg
assert "Example:" in msg
assert example_snippet in msg
assert exc_info.value.__cause__ is None


def test_missing_fields_do_not_raise_bare_pydantic_validation_error() -> None:
with pytest.raises(ConfigurationError):
Index()
with pytest.raises(ConfigurationError):
Option()


def test_invalid_field_type_still_wrapped() -> None:
with pytest.raises(ConfigurationError) as exc_info:
Equity(exchange=Exchange.NSE, symbol=123) # type: ignore[arg-type]

msg = str(exc_info.value)
assert msg.startswith("Invalid Equity:")
assert "Example:" in msg
assert 'Equity(exchange=Exchange.NSE, symbol="RELIANCE")' in msg


def test_valid_construction_unchanged() -> None:
idx = Index(exchange=Exchange.NSE, symbol="NIFTY")
assert idx.exchange == Exchange.NSE
assert idx.symbol == "NIFTY"

fut = Future(exchange=Exchange.NFO, symbol="NIFTY", expiry=date(2026, 3, 27))
assert fut.expiry == date(2026, 3, 27)

opt = Option(
exchange=Exchange.NFO,
symbol="NIFTY",
expiry=date(2026, 3, 27),
strike=25000,
option_type=OptionType.CE,
)
assert opt.strike == 25000
assert opt.option_type == OptionType.CE


def test_compact_repr() -> None:
assert repr(Index(exchange=Exchange.NSE, symbol="NIFTY")) == "Index(NSE:NIFTY)"
assert repr(Equity(exchange=Exchange.NSE, symbol="RELIANCE")) == "Equity(NSE:RELIANCE)"
assert (
repr(Future(exchange=Exchange.NFO, symbol="NIFTY", expiry=date(2026, 3, 27)))
== "Future(NFO:NIFTY 2026-03-27)"
)
assert (
repr(
Option(
exchange=Exchange.NFO,
symbol="NIFTY",
expiry=date(2026, 3, 27),
strike=25000.0,
option_type=OptionType.CE,
)
)
== "Option(NFO:NIFTY 25000 CE 2026-03-27)"
)


def test_base_instrument_also_wrapped() -> None:
with pytest.raises(ConfigurationError) as exc_info:
Instrument()
assert "Instrument requires exchange and symbol" in str(exc_info.value)


def test_unrelated_models_still_use_pydantic_validation_error() -> None:
"""Instrument wrapping must not change other Pydantic models."""
from tt_connect.core.models import Profile

with pytest.raises(ValidationError):
Profile(client_id="ZZ0001") # missing name, email
131 changes: 122 additions & 9 deletions tt_connect/core/models/instruments.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,106 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from pydantic import BaseModel, ConfigDict
from typing import Any

from pydantic import BaseModel, ConfigDict, ValidationError

from tt_connect.core.exceptions import ConfigurationError
from tt_connect.core.models.enums import Exchange, OptionType

_CONSTRUCTION_EXAMPLES: dict[str, str] = {
"Instrument": 'Instrument(exchange=Exchange.NSE, symbol="RELIANCE")',
"Index": 'Index(exchange=Exchange.NSE, symbol="NIFTY")',
"Equity": 'Equity(exchange=Exchange.NSE, symbol="RELIANCE")',
"Future": (
'Future(exchange=Exchange.NFO, symbol="NIFTY", expiry=date(2026, 3, 27))'
),
"Option": (
'Option(exchange=Exchange.NFO, symbol="NIFTY", expiry=date(2026, 3, 27), '
"strike=25000, option_type=OptionType.CE)"
),
"Currency": 'Currency(exchange=Exchange.CDS, symbol="USDINR")',
"Commodity": 'Commodity(exchange=Exchange.MCX, symbol="GOLD")',
}


def _join_field_names(fields: list[str]) -> str:
if len(fields) == 1:
return fields[0]
if len(fields) == 2:
return f"{fields[0]} and {fields[1]}"
return ", ".join(fields[:-1]) + f", and {fields[-1]}"


def _instrument_construction_error(cls: type[Any], exc: ValidationError) -> str:
"""Build a short, example-backed construction error for public instrument types."""
name = cls.__name__
example = _CONSTRUCTION_EXAMPLES.get(
name, f'{name}(exchange=Exchange.NSE, symbol="SYMBOL")'
)
missing = [
str(err["loc"][0])
for err in exc.errors()
if err["type"] == "missing" and err.get("loc")
]
if missing:
return (
f"{name} requires {_join_field_names(missing)}.\n"
f" Example: {example}"
)
details = "; ".join(
f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
return f"Invalid {name}: {details}\n Example: {example}"


class Instrument(BaseModel):
"""Canonical base instrument shape accepted by client APIs."""
"""Canonical base instrument shape accepted by client APIs.

Example::

Instrument(exchange=Exchange.NSE, symbol="RELIANCE")
"""

model_config = ConfigDict(frozen=True)
exchange: Exchange
symbol: str

def __init__(self, *args: Any, **kwargs: Any) -> None:
try:
super().__init__(*args, **kwargs)
except ValidationError as exc:
raise ConfigurationError(
_instrument_construction_error(type(self), exc)
) from None

def __repr__(self) -> str:
return f"{type(self).__name__}({self.exchange}:{self.symbol})"


class Index(Instrument):
"""A market index (NIFTY 50, SENSEX, NIFTY BANK etc.).

Not directly tradeable — used for LTP subscription and as the canonical
underlying reference for index F&O.

Example::

Index(exchange=Exchange.NSE, symbol="NIFTY")
"""
A market index (NIFTY 50, SENSEX, NIFTY BANK etc.).
Not directly tradeable — used for LTP subscription and as
the canonical underlying reference for index F&O.
"""

exchange: Exchange


class Equity(Instrument):
"""Cash-market equity or ETF."""
"""Cash-market equity or ETF.

Example::

Equity(exchange=Exchange.NSE, symbol="RELIANCE")
"""

exchange: Exchange

Expand All @@ -33,31 +110,67 @@ class Future(Instrument):

Validity (does this expiry exist?) is checked at resolve time by
``InstrumentResolver``, not at construction.

Example::

Future(exchange=Exchange.NFO, symbol="NIFTY", expiry=date(2026, 3, 27))
"""

expiry: date

def __repr__(self) -> str:
return f"Future({self.exchange}:{self.symbol} {self.expiry.isoformat()})"


class Option(Instrument):
"""Options contract keyed by underlying symbol, expiry, strike and side.

Validity (does this contract exist?) is checked at resolve time by
``InstrumentResolver``, not at construction.

Example::

Option(
exchange=Exchange.NFO,
symbol="NIFTY",
expiry=date(2026, 3, 27),
strike=25000,
option_type=OptionType.CE,
)
"""

expiry: date
strike: float
option_type: OptionType

def __repr__(self) -> str:
strike: float | int = (
int(self.strike) if self.strike == int(self.strike) else self.strike
)
return (
f"Option({self.exchange}:{self.symbol} {strike} "
f"{self.option_type} {self.expiry.isoformat()})"
)


class Currency(Instrument):
"""Currency derivative instrument."""
"""Currency derivative instrument.

Example::

Currency(exchange=Exchange.CDS, symbol="USDINR")
"""

exchange: Exchange


class Commodity(Instrument):
"""Commodity derivative instrument."""
"""Commodity derivative instrument.

Example::

Commodity(exchange=Exchange.MCX, symbol="GOLD")
"""

exchange: Exchange

Expand Down
Loading