From 672c5eae2c7670bfe23144b9db70035cdd28caa7 Mon Sep 17 00:00:00 2001 From: Apurv-Salunke Date: Tue, 4 Aug 2026 04:58:46 +0530 Subject: [PATCH] Improve instrument construction errors and compact reprs. Raise ConfigurationError with a usage example instead of a raw Pydantic ValidationError when Index/Equity/Future/Option fields are missing, and tighten __repr__ for REPL readability. Co-authored-by: Cursor --- CHANGELOG.md | 4 + tests/unit/test_instrument_models.py | 114 ++++++++++++++++++++++ tt_connect/core/models/instruments.py | 131 ++++++++++++++++++++++++-- 3 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_instrument_models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d9efa..7800b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/tests/unit/test_instrument_models.py b/tests/unit/test_instrument_models.py new file mode 100644 index 0000000..3a19fca --- /dev/null +++ b/tests/unit/test_instrument_models.py @@ -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 diff --git a/tt_connect/core/models/instruments.py b/tt_connect/core/models/instruments.py index 939113f..e885be2 100644 --- a/tt_connect/core/models/instruments.py +++ b/tt_connect/core/models/instruments.py @@ -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 @@ -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