agentii-models 0.1.0__py3-none-any.whl

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,60 @@
1
+ """agentii-models: Shared Pydantic v2 data models for Agentii and Agenzym."""
2
+
3
+ # Instruments
4
+ from .instruments import Instrument, EquityInstrument, OptionInstrument
5
+
6
+ # Stocks
7
+ from .stocks import StockTick, StockQuote, StockBar, StockSnapshot
8
+
9
+ # Options
10
+ from .options import Greeks, OptionQuote, OptionBar, OptionsChain
11
+
12
+ # Analytics
13
+ from .analytics import IVPoint, VolatilitySurface, OptionPricingResult, PayoffDiagram
14
+
15
+ # Portfolio
16
+ from .portfolio import StockPosition, OptionPosition, Portfolio
17
+
18
+ # Orders
19
+ from .orders import Order, Trade
20
+
21
+ # Biotech
22
+ from .biotech import CatalystEvent, FDADecision
23
+
24
+ # Providers
25
+ from .providers import MarketDataProvider, DataRouter
26
+
27
+ __all__ = [
28
+ # Instruments
29
+ "Instrument",
30
+ "EquityInstrument",
31
+ "OptionInstrument",
32
+ # Stocks
33
+ "StockTick",
34
+ "StockQuote",
35
+ "StockBar",
36
+ "StockSnapshot",
37
+ # Options
38
+ "Greeks",
39
+ "OptionQuote",
40
+ "OptionBar",
41
+ "OptionsChain",
42
+ # Analytics
43
+ "IVPoint",
44
+ "VolatilitySurface",
45
+ "OptionPricingResult",
46
+ "PayoffDiagram",
47
+ # Portfolio
48
+ "StockPosition",
49
+ "OptionPosition",
50
+ "Portfolio",
51
+ # Orders
52
+ "Order",
53
+ "Trade",
54
+ # Biotech
55
+ "CatalystEvent",
56
+ "FDADecision",
57
+ # Providers
58
+ "MarketDataProvider",
59
+ "DataRouter",
60
+ ]
@@ -0,0 +1,39 @@
1
+ """Base market data model shared by all market data types."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, field_validator
4
+
5
+ from .types import ProviderName, Symbol, Timestamp
6
+
7
+
8
+ class BaseMarketData(BaseModel):
9
+ model_config = ConfigDict(
10
+ frozen=True,
11
+ populate_by_name=True,
12
+ )
13
+
14
+ symbol: Symbol
15
+ provider: ProviderName
16
+ timestamp_ns: Timestamp | None = None
17
+
18
+ @field_validator("symbol", mode="before")
19
+ @classmethod
20
+ def _validate_symbol(cls, v: str) -> str:
21
+ v = str(v).strip().upper()
22
+ if not v:
23
+ raise ValueError("symbol must be non-empty")
24
+ return v
25
+
26
+ @field_validator("provider", mode="before")
27
+ @classmethod
28
+ def _validate_provider(cls, v: str) -> str:
29
+ v = str(v).strip()
30
+ if not v:
31
+ raise ValueError("provider must be non-empty")
32
+ return v
33
+
34
+ @field_validator("timestamp_ns")
35
+ @classmethod
36
+ def _validate_timestamp_ns(cls, v: int | None) -> int | None:
37
+ if v is not None and v < 0:
38
+ raise ValueError("timestamp_ns must be non-negative")
39
+ return v
@@ -0,0 +1,87 @@
1
+ """Analytics models: IVPoint, VolatilitySurface, OptionPricingResult, PayoffDiagram.
2
+
3
+ Structural reference: VeighNa vnpy_optionmaster/pricing/black_scholes.py
4
+ - calculate_greeks(s, k, r, t, v, cp) → (price, delta, gamma, theta, vega)
5
+ """
6
+
7
+ from datetime import date, datetime
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from .enums import OptionType, PricingModel
12
+ from .types import Price, ProviderName, Symbol
13
+
14
+
15
+ class IVPoint(BaseModel):
16
+ model_config = ConfigDict(frozen=True)
17
+
18
+ underlying_symbol: Symbol
19
+ strike: Price
20
+ expiration: date
21
+ option_type: OptionType
22
+ implied_volatility: float
23
+ timestamp: datetime
24
+ provider: ProviderName
25
+
26
+
27
+ class VolatilitySurface(BaseModel):
28
+ """3D IV surface: strike × expiry × IV."""
29
+
30
+ model_config = ConfigDict(frozen=True)
31
+
32
+ underlying_symbol: Symbol
33
+ snapshot_time: datetime
34
+ provider: ProviderName
35
+ points: list[IVPoint]
36
+
37
+ def iv_at(self, strike: float, expiration: date) -> float | None:
38
+ for p in self.points:
39
+ if p.strike == strike and p.expiration == expiration:
40
+ return p.implied_volatility
41
+ return None
42
+
43
+ def smile(self, expiration: date) -> list[IVPoint]:
44
+ return sorted(
45
+ [p for p in self.points if p.expiration == expiration],
46
+ key=lambda p: p.strike,
47
+ )
48
+
49
+ def term_structure(self, strike: float) -> list[IVPoint]:
50
+ return sorted(
51
+ [p for p in self.points if p.strike == strike],
52
+ key=lambda p: p.expiration,
53
+ )
54
+
55
+
56
+ class OptionPricingResult(BaseModel):
57
+ """Output of a pricing model calculation."""
58
+
59
+ model_config = ConfigDict(frozen=True)
60
+
61
+ model: PricingModel
62
+ theoretical_price: Price
63
+ delta: float
64
+ gamma: float
65
+ theta: float
66
+ vega: float
67
+ rho: float
68
+ implied_volatility: float
69
+ underlying_price: Price
70
+ strike: Price
71
+ risk_free_rate: float
72
+ time_to_expiry: float
73
+ option_type: OptionType
74
+
75
+
76
+ class PayoffDiagram(BaseModel):
77
+ """Strategy payoff visualization data."""
78
+
79
+ model_config = ConfigDict(frozen=True)
80
+
81
+ strategy_name: str
82
+ underlying_symbol: Symbol
83
+ price_points: list[float]
84
+ payoff_values: list[float]
85
+ breakeven_points: list[float]
86
+ max_profit: float | None = None
87
+ max_loss: float | None = None
@@ -0,0 +1,59 @@
1
+ """Biotech-specific models: CatalystEvent, FDADecision."""
2
+
3
+ from datetime import date, datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, computed_field, field_validator
6
+
7
+ from .enums import CatalystType, FDADecisionOutcome
8
+ from .types import Price, Symbol
9
+
10
+
11
+ class CatalystEvent(BaseModel):
12
+ event_id: str
13
+ symbol: Symbol
14
+ drug_name: str
15
+ indication: str
16
+ catalyst_type: CatalystType
17
+ event_date: date | None = None
18
+ date_is_estimated: bool = False
19
+ approval_probability: float | None = None
20
+ expected_move_pct: float | None = None
21
+ historical_precedent: str | None = None
22
+ source: str | None = None
23
+ notes: str | None = None
24
+ created_at: datetime
25
+ updated_at: datetime | None = None
26
+
27
+ @field_validator("approval_probability")
28
+ @classmethod
29
+ def _validate_probability(cls, v: float | None) -> float | None:
30
+ if v is not None and not (0.0 <= v <= 1.0):
31
+ raise ValueError("approval_probability must be between 0.0 and 1.0")
32
+ return v
33
+
34
+
35
+ class FDADecision(BaseModel):
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ decision_id: str
39
+ symbol: Symbol
40
+ drug_name: str
41
+ indication: str
42
+ outcome: FDADecisionOutcome
43
+ decision_date: date
44
+ catalyst_event_id: str | None = None
45
+ price_before: Price | None = None
46
+ price_after: Price | None = None
47
+ review_type: str | None = None
48
+ label_expansion: bool = False
49
+ advisory_committee_vote: str | None = None
50
+ crl_reasons: list[str] | None = None
51
+ source_url: str | None = None
52
+ decision_letter_url: str | None = None
53
+
54
+ @computed_field # type: ignore[prop-decorator]
55
+ @property
56
+ def actual_move_pct(self) -> float | None:
57
+ if self.price_before is not None and self.price_after is not None and self.price_before != 0:
58
+ return (self.price_after - self.price_before) / self.price_before * 100
59
+ return None
@@ -0,0 +1,132 @@
1
+ """Shared enums for agentii-models."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class AssetClass(str, Enum):
7
+ EQUITY = "equity"
8
+ OPTION = "option"
9
+ ETF = "etf"
10
+
11
+
12
+ class Exchange(str, Enum):
13
+ NYSE = "NYSE"
14
+ NASDAQ = "NASDAQ"
15
+ CBOE = "CBOE"
16
+ AMEX = "AMEX"
17
+ ARCA = "ARCA"
18
+ BATS = "BATS"
19
+ IEX = "IEX"
20
+ OTC = "OTC"
21
+ OPRA = "OPRA"
22
+
23
+
24
+ class OptionType(str, Enum):
25
+ CALL = "call"
26
+ PUT = "put"
27
+
28
+
29
+ class OptionStyle(str, Enum):
30
+ AMERICAN = "american"
31
+ EUROPEAN = "european"
32
+
33
+
34
+ class BarTimeframe(str, Enum):
35
+ TICK = "tick"
36
+ SEC_1 = "1s"
37
+ MIN_1 = "1min"
38
+ MIN_5 = "5min"
39
+ MIN_15 = "15min"
40
+ MIN_30 = "30min"
41
+ HOUR_1 = "1h"
42
+ HOUR_4 = "4h"
43
+ DAY_1 = "1d"
44
+ WEEK_1 = "1w"
45
+ MONTH_1 = "1mo"
46
+
47
+
48
+ class MarketSession(str, Enum):
49
+ PRE_MARKET = "pre"
50
+ REGULAR = "regular"
51
+ POST_MARKET = "post"
52
+
53
+
54
+ class OrderSide(str, Enum):
55
+ BUY = "buy"
56
+ SELL = "sell"
57
+
58
+
59
+ class OrderType(str, Enum):
60
+ MARKET = "market"
61
+ LIMIT = "limit"
62
+ STOP = "stop"
63
+ STOP_LIMIT = "stop_limit"
64
+ TRAILING_STOP = "trailing_stop"
65
+
66
+
67
+ class OrderStatus(str, Enum):
68
+ PENDING = "pending"
69
+ ACCEPTED = "accepted"
70
+ PARTIALLY_FILLED = "partially_filled"
71
+ FILLED = "filled"
72
+ CANCELLED = "cancelled"
73
+ REJECTED = "rejected"
74
+ EXPIRED = "expired"
75
+
76
+
77
+ class TimeInForce(str, Enum):
78
+ DAY = "day"
79
+ GTC = "gtc"
80
+ IOC = "ioc"
81
+ FOK = "fok"
82
+ OPG = "opg"
83
+ CLS = "cls"
84
+
85
+
86
+ class CatalystType(str, Enum):
87
+ PDUFA = "pdufa"
88
+ ADCOM = "adcom"
89
+ PHASE_1 = "phase_1"
90
+ PHASE_2 = "phase_2"
91
+ PHASE_3 = "phase_3"
92
+ NDA_FILING = "nda_filing"
93
+ BLA_FILING = "bla_filing"
94
+ CONFERENCE = "conference"
95
+ EARNINGS = "earnings"
96
+ DATA_READOUT = "data_readout"
97
+ PRIORITY_REVIEW = "priority_review"
98
+ BREAKTHROUGH = "breakthrough"
99
+
100
+
101
+ class FDADecisionOutcome(str, Enum):
102
+ APPROVED = "approved"
103
+ CRL = "crl"
104
+ TENTATIVE_APPROVAL = "tentative_approval"
105
+ REFUSED_TO_FILE = "refused_to_file"
106
+ WITHDRAWN = "withdrawn"
107
+ PENDING = "pending"
108
+
109
+
110
+ class DataFeed(str, Enum):
111
+ """Alpaca data feed source. Affects data quality, latency, and cost."""
112
+
113
+ SIP = "sip"
114
+ IEX = "iex"
115
+ OTC = "otc"
116
+ BOATS = "boats"
117
+
118
+
119
+ class Adjustment(str, Enum):
120
+ """Price adjustment type for historical bars.
121
+ Critical for 10-year historical data accuracy."""
122
+
123
+ RAW = "raw"
124
+ SPLIT = "split"
125
+ DIVIDEND = "dividend"
126
+ ALL = "all"
127
+
128
+
129
+ class PricingModel(str, Enum):
130
+ BLACK_SCHOLES = "black_scholes"
131
+ BLACK_76 = "black_76"
132
+ BINOMIAL_TREE = "binomial_tree"
@@ -0,0 +1,85 @@
1
+ """Instrument hierarchy: Instrument, EquityInstrument, OptionInstrument."""
2
+
3
+ import re
4
+ from datetime import date
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, computed_field, field_validator
8
+
9
+ from .enums import AssetClass, Exchange, OptionType, OptionStyle
10
+ from .types import Symbol
11
+
12
+ # OCC symbol: 1-6 uppercase letters (right-padded with spaces to 6), 6-digit date YYMMDD, C or P, 8-digit strike
13
+ _OCC_PATTERN = re.compile(r"^[A-Z]{1,6}\s*\d{6}[CP]\d{8}$")
14
+
15
+
16
+ class Instrument(BaseModel):
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+ symbol: Symbol
20
+ asset_class: AssetClass
21
+ exchange: Exchange
22
+ name: str
23
+ currency: str = "USD"
24
+
25
+
26
+ class EquityInstrument(Instrument):
27
+ asset_class: Literal[AssetClass.EQUITY] = AssetClass.EQUITY
28
+
29
+ sector: str | None = None
30
+ industry: str | None = None
31
+ market_cap: float | None = None
32
+ has_fda_pipeline: bool = False
33
+ clinical_stage: str | None = None
34
+ is_biotech: bool = False
35
+
36
+
37
+ class OptionInstrument(Instrument):
38
+ asset_class: Literal[AssetClass.OPTION] = AssetClass.OPTION
39
+
40
+ underlying_symbol: Symbol
41
+ strike_price: float
42
+ expiration_date: date
43
+ option_type: OptionType
44
+ option_style: OptionStyle = OptionStyle.AMERICAN
45
+ contract_size: int = 100
46
+
47
+ @field_validator("symbol")
48
+ @classmethod
49
+ def _validate_occ_symbol(cls, v: str) -> str:
50
+ if not _OCC_PATTERN.match(v):
51
+ raise ValueError(
52
+ f"Invalid OCC symbol format: '{v}'. "
53
+ "Expected: 1-6 uppercase letters + 6-digit date YYMMDD + C/P + 8-digit strike"
54
+ )
55
+ return v
56
+
57
+ @field_validator("strike_price")
58
+ @classmethod
59
+ def _validate_strike(cls, v: float) -> float:
60
+ if v <= 0:
61
+ raise ValueError("strike_price must be positive")
62
+ return v
63
+
64
+ @field_validator("contract_size")
65
+ @classmethod
66
+ def _validate_contract_size(cls, v: int) -> int:
67
+ if v <= 0:
68
+ raise ValueError("contract_size must be positive")
69
+ return v
70
+
71
+ @computed_field # type: ignore[prop-decorator]
72
+ @property
73
+ def days_to_expiry(self) -> int:
74
+ return (self.expiration_date - date.today()).days
75
+
76
+ @computed_field # type: ignore[prop-decorator]
77
+ @property
78
+ def is_expired(self) -> bool:
79
+ return self.expiration_date < date.today()
80
+
81
+ def moneyness(self, underlying_price: float) -> float:
82
+ """Return strike / underlying price ratio."""
83
+ if underlying_price <= 0:
84
+ raise ValueError("underlying_price must be positive")
85
+ return self.strike_price / underlying_price
@@ -0,0 +1,245 @@
1
+ """Options models: Greeks, OptionQuote, OptionBar, OptionsChain.
2
+
3
+ Provider field mapping (Alpaca Snapshot → agentii-models):
4
+ latestQuote.bp → bid, latestQuote.bs → bid_size,
5
+ latestQuote.ap → ask, latestQuote.as → ask_size,
6
+ latestTrade.p → last_trade_price, latestTrade.s → last_trade_size,
7
+ greeks.delta → delta, greeks.gamma → gamma, greeks.theta → theta,
8
+ greeks.vega → vega, greeks.rho → rho,
9
+ impliedVolatility → implied_volatility, openInterest → open_interest
10
+
11
+ Provider field mapping (Polygon Snapshot → agentii-models):
12
+ last_quote.bid → bid, last_quote.ask → ask,
13
+ last_trade.price → last_trade_price, last_trade.size → last_trade_size,
14
+ greeks.delta → delta, greeks.gamma → gamma, greeks.theta → theta,
15
+ greeks.vega → vega, implied_volatility → implied_volatility,
16
+ open_interest → open_interest
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from datetime import date, datetime
22
+ from typing import Any
23
+
24
+ from pydantic import BaseModel, ConfigDict, computed_field, field_validator
25
+
26
+ from ._base import BaseMarketData
27
+ from .enums import BarTimeframe, OptionType
28
+ from .types import Price, ProviderName, Symbol, Volume
29
+
30
+ _INTERDAY_TIMEFRAMES = frozenset({BarTimeframe.DAY_1, BarTimeframe.WEEK_1, BarTimeframe.MONTH_1})
31
+
32
+
33
+ class Greeks(BaseModel):
34
+ """Position-level Greeks container for aggregation.
35
+ Used by OptionPosition.position_greeks and Portfolio.net_greeks.
36
+ NOT embedded on OptionQuote (those are flat fields)."""
37
+
38
+ model_config = ConfigDict(frozen=True)
39
+
40
+ delta: float = 0.0
41
+ gamma: float = 0.0
42
+ theta: float = 0.0
43
+ vega: float = 0.0
44
+ rho: float = 0.0
45
+ implied_volatility: float = 0.0
46
+
47
+ @computed_field # type: ignore[prop-decorator]
48
+ @property
49
+ def is_complete(self) -> bool:
50
+ return all(
51
+ v != 0.0
52
+ for v in (self.delta, self.gamma, self.theta, self.vega, self.rho, self.implied_volatility)
53
+ )
54
+
55
+
56
+ class OptionQuote(BaseMarketData):
57
+ """Real-time option contract quote with flat Greeks. Full OpenBB 35+ field set — all Optional."""
58
+
59
+ # Identity
60
+ underlying_symbol: Symbol
61
+ contract_symbol: Symbol
62
+ expiration: date
63
+ dte: int | None = None
64
+ strike: Price
65
+ option_type: OptionType
66
+ contract_size: int = 100
67
+
68
+ # Pricing — bid/ask
69
+ bid: Price | None = None
70
+ bid_size: Volume | None = None
71
+ bid_exchange: str | None = None
72
+ bid_time: datetime | None = None
73
+ ask: Price | None = None
74
+ ask_size: Volume | None = None
75
+ ask_exchange: str | None = None
76
+ ask_time: datetime | None = None
77
+ mark: Price | None = None
78
+
79
+ # OHLC
80
+ open: Price | None = None
81
+ high: Price | None = None
82
+ low: Price | None = None
83
+ close: Price | None = None
84
+ open_bid: Price | None = None
85
+ open_ask: Price | None = None
86
+ bid_high: Price | None = None
87
+ ask_high: Price | None = None
88
+ bid_low: Price | None = None
89
+ ask_low: Price | None = None
90
+ close_size: Volume | None = None
91
+ close_time: datetime | None = None
92
+ close_bid: Price | None = None
93
+ close_bid_size: Volume | None = None
94
+ close_bid_time: datetime | None = None
95
+ close_ask: Price | None = None
96
+ close_ask_size: Volume | None = None
97
+ close_ask_time: datetime | None = None
98
+
99
+ # Trade
100
+ last_trade_price: Price | None = None
101
+ last_trade_size: Volume | None = None
102
+ last_trade_time: datetime | None = None
103
+ tick: str | None = None
104
+
105
+ # Change
106
+ prev_close: Price | None = None
107
+ change: float | None = None
108
+ change_percent: float | None = None
109
+
110
+ # Volume / OI
111
+ volume: Volume | None = None
112
+ open_interest: int | None = None
113
+
114
+ # Greeks — FLAT on quote (not nested)
115
+ implied_volatility: float | None = None
116
+ delta: float | None = None
117
+ gamma: float | None = None
118
+ theta: float | None = None
119
+ vega: float | None = None
120
+ rho: float | None = None
121
+ theoretical_price: Price | None = None
122
+
123
+ @computed_field # type: ignore[prop-decorator]
124
+ @property
125
+ def mid_price(self) -> float | None:
126
+ if self.bid is not None and self.ask is not None:
127
+ return (self.bid + self.ask) / 2
128
+ return None
129
+
130
+ @computed_field # type: ignore[prop-decorator]
131
+ @property
132
+ def spread(self) -> float | None:
133
+ if self.bid is not None and self.ask is not None:
134
+ return self.ask - self.bid
135
+ return None
136
+
137
+ @computed_field # type: ignore[prop-decorator]
138
+ @property
139
+ def has_greeks(self) -> bool:
140
+ return all(
141
+ v is not None
142
+ for v in (self.delta, self.gamma, self.theta, self.vega, self.rho, self.implied_volatility)
143
+ )
144
+
145
+
146
+ class OptionBar(BaseMarketData):
147
+ """OHLCV bar for a single option contract."""
148
+
149
+ contract_symbol: Symbol
150
+ underlying_symbol: Symbol
151
+ date: date | datetime
152
+ timeframe: BarTimeframe
153
+ open: Price
154
+ high: Price
155
+ low: Price
156
+ close: Price
157
+ volume: Volume | None = None
158
+ open_interest: int | None = None
159
+ vwap: Price | None = None
160
+ trade_count: int | None = None
161
+
162
+ @computed_field # type: ignore[prop-decorator]
163
+ @property
164
+ def is_intraday(self) -> bool:
165
+ return self.timeframe not in _INTERDAY_TIMEFRAMES
166
+
167
+
168
+ class OptionsChain(BaseModel):
169
+ """Full chain snapshot for a single underlying.
170
+ Stores contracts as list[OptionQuote] (per-contract objects)."""
171
+
172
+ model_config = ConfigDict(frozen=True)
173
+
174
+ underlying_symbol: Symbol
175
+ snapshot_time: datetime
176
+ provider: ProviderName
177
+ contracts: list[OptionQuote]
178
+
179
+ def filter(
180
+ self,
181
+ option_type: OptionType | None = None,
182
+ expiration: date | None = None,
183
+ min_strike: float | None = None,
184
+ max_strike: float | None = None,
185
+ min_volume: int | None = None,
186
+ min_open_interest: int | None = None,
187
+ ) -> list[OptionQuote]:
188
+ result = self.contracts
189
+ if option_type is not None:
190
+ result = [c for c in result if c.option_type == option_type]
191
+ if expiration is not None:
192
+ result = [c for c in result if c.expiration == expiration]
193
+ if min_strike is not None:
194
+ result = [c for c in result if c.strike >= min_strike]
195
+ if max_strike is not None:
196
+ result = [c for c in result if c.strike <= max_strike]
197
+ if min_volume is not None:
198
+ result = [c for c in result if c.volume is not None and c.volume >= min_volume]
199
+ if min_open_interest is not None:
200
+ result = [c for c in result if c.open_interest is not None and c.open_interest >= min_open_interest]
201
+ return result
202
+
203
+ def by_expiry(self, expiration: date) -> list[OptionQuote]:
204
+ return [c for c in self.contracts if c.expiration == expiration]
205
+
206
+ def expirations(self) -> list[date]:
207
+ return sorted({c.expiration for c in self.contracts})
208
+
209
+ def strikes(self, expiration: date | None = None) -> list[float]:
210
+ contracts = self.contracts if expiration is None else self.by_expiry(expiration)
211
+ return sorted({c.strike for c in contracts})
212
+
213
+ def atm_strike(self, underlying_price: float) -> float | None:
214
+ all_strikes = self.strikes()
215
+ if not all_strikes:
216
+ return None
217
+ return min(all_strikes, key=lambda s: abs(s - underlying_price))
218
+
219
+ def near_term_expiry(self) -> date | None:
220
+ today = date.today()
221
+ future = [e for e in self.expirations() if e >= today]
222
+ return future[0] if future else None
223
+
224
+ def to_dataframe(self) -> Any:
225
+ """Convert contracts to a pandas DataFrame. Requires pandas."""
226
+ try:
227
+ import pandas as pd
228
+ except ImportError:
229
+ raise ImportError("pandas is required for to_dataframe(). Install with: pip install pandas")
230
+ return pd.DataFrame([c.model_dump() for c in self.contracts])
231
+
232
+ @computed_field # type: ignore[prop-decorator]
233
+ @property
234
+ def call_count(self) -> int:
235
+ return sum(1 for c in self.contracts if c.option_type == OptionType.CALL)
236
+
237
+ @computed_field # type: ignore[prop-decorator]
238
+ @property
239
+ def put_count(self) -> int:
240
+ return sum(1 for c in self.contracts if c.option_type == OptionType.PUT)
241
+
242
+ @computed_field # type: ignore[prop-decorator]
243
+ @property
244
+ def total_contracts(self) -> int:
245
+ return len(self.contracts)