main
py 175 lines 6.35 KB
Raw
1 from __future__ import annotations
2
3 import math
4 import re
5 from datetime import datetime
6 from decimal import Decimal
7 from enum import StrEnum
8 from typing import Any, Literal
9 from uuid import UUID
10
11 from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
13
14 TOOL_SCHEMA_VERSION = "YAHOO_FINANCE_MCP_TOOL_V1"
15 ADAPTER_VERSION = "FIRST_PARTY_YAHOO_MCP_V1"
16 _SYMBOL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.^=_-]{0,39}$")
17
18
19 class StrictModel(BaseModel):
20 model_config = ConfigDict(extra="forbid", populate_by_name=True)
21
22
23 class Region(StrEnum):
24 INDIA = "INDIA"
25 USA = "USA"
26 EUROPE = "EUROPE"
27
28
29 class IdentityInput(StrictModel):
30 global_instrument_id: UUID = Field(alias="globalInstrumentId")
31 verified_yahoo_symbol: str = Field(alias="verifiedYahooSymbol", min_length=1, max_length=40)
32 region: Region
33 exchange: str | None = Field(default=None, max_length=100)
34 currency: str | None = Field(default=None, min_length=3, max_length=20)
35
36 @field_validator("verified_yahoo_symbol")
37 @classmethod
38 def valid_symbol(cls, value: str) -> str:
39 result = value.strip().upper()
40 if not _SYMBOL.fullmatch(result):
41 raise ValueError("verifiedYahooSymbol is invalid")
42 return result
43
44 @field_validator("exchange")
45 @classmethod
46 def clean_exchange(cls, value: str | None) -> str | None:
47 if value is None:
48 return None
49 result = value.strip().upper()
50 return result or None
51
52 @field_validator("currency")
53 @classmethod
54 def clean_currency(cls, value: str | None) -> str | None:
55 return value.strip().upper() if value else None
56
57
58 class HistoryInput(IdentityInput):
59 lookback_days: int = Field(default=400, alias="lookbackDays", ge=2, le=3650)
60
61
62 class NewsInput(IdentityInput):
63 days: int = Field(default=30, ge=1, le=30)
64
65
66 class RawFact(StrictModel):
67 metric: str = Field(min_length=1, max_length=100)
68 value: Any
69 unit: str | None = Field(default=None, max_length=30)
70 period_end: str | None = Field(default=None, alias="periodEnd")
71 period_type: str | None = Field(default=None, alias="periodType")
72 reporting_basis: str = Field(default="UNKNOWN", alias="reportingBasis")
73 as_of: datetime | None = Field(default=None, alias="asOf")
74 published_at: datetime | None = Field(default=None, alias="publishedAt")
75 confidence: float = Field(default=0.78, ge=0, le=1)
76 raw_field_origin: str | None = Field(default=None, alias="rawFieldOrigin", max_length=200)
77
78 @field_validator("value")
79 @classmethod
80 def finite_scalar(cls, value: Any) -> Any:
81 if isinstance(value, (dict, list, tuple)):
82 raise ValueError("fact value must be scalar")
83 if isinstance(value, float) and not math.isfinite(value):
84 raise ValueError("fact value must be finite")
85 if isinstance(value, Decimal) and not value.is_finite():
86 raise ValueError("fact value must be finite")
87 return value
88
89 @field_validator("period_end")
90 @classmethod
91 def valid_period_end(cls, value: str | None) -> str | None:
92 if value is None:
93 return None
94 try:
95 datetime.fromisoformat(value)
96 except ValueError as exc:
97 raise ValueError("periodEnd must be an ISO date") from exc
98 return value
99
100 @field_validator("period_type")
101 @classmethod
102 def valid_period_type(cls, value: str | None) -> str | None:
103 if value is None:
104 return None
105 result = value.strip().upper()
106 if result not in {"ANNUAL", "QUARTERLY", "AS_AT"}:
107 raise ValueError("periodType is unsupported")
108 return result
109
110
111 class RawPrice(StrictModel):
112 observed_at: datetime = Field(alias="observedAt")
113 close: Decimal
114 currency: str | None = None
115
116 @field_validator("close")
117 @classmethod
118 def positive_finite(cls, value: Decimal) -> Decimal:
119 if not value.is_finite() or value <= 0:
120 raise ValueError("close must be positive and finite")
121 return value
122
123
124 class RawProfile(StrictModel):
125 company_name: str | None = Field(default=None, alias="companyName", max_length=300)
126 sector: str | None = Field(default=None, max_length=200)
127 industry: str | None = Field(default=None, max_length=200)
128
129
130 class RawArticle(StrictModel):
131 headline: str = Field(min_length=1, max_length=500)
132 url: str = Field(min_length=1, max_length=2048)
133 published_at: datetime = Field(alias="publishedAt")
134 publisher: str = Field(default="Yahoo Finance", max_length=200)
135 issuer_symbol: str = Field(alias="issuerSymbol", min_length=1, max_length=40)
136 summary: str | None = Field(default=None, max_length=2000)
137
138 @field_validator("url")
139 @classmethod
140 def http_url(cls, value: str) -> str:
141 if not value.startswith(("https://", "http://")):
142 raise ValueError("news URL must use HTTP(S)")
143 return value
144
145
146 class ToolPayload(StrictModel):
147 schema_version: str = Field(default=TOOL_SCHEMA_VERSION, alias="schemaVersion")
148 adapter_version: str = Field(default=ADAPTER_VERSION, alias="adapterVersion")
149 source: Literal["YAHOO_FINANCE"] = "YAHOO_FINANCE"
150 global_instrument_id: UUID = Field(alias="globalInstrumentId")
151 symbol: str = Field(min_length=1, max_length=40)
152 exchange: str | None = Field(default=None, max_length=100)
153 currency: str | None = Field(default=None, max_length=20)
154 as_of: datetime | None = Field(default=None, alias="asOf")
155 retrieved_at: datetime = Field(alias="retrievedAt")
156 source_url: str = Field(alias="sourceUrl", min_length=1, max_length=2048)
157 price: Decimal | None = None
158 facts: tuple[RawFact, ...] = ()
159 prices: tuple[RawPrice, ...] = ()
160 profile: RawProfile | None = None
161 news: tuple[RawArticle, ...] = ()
162 news_query_succeeded: bool = Field(default=False, alias="newsQuerySucceeded")
163
164 @model_validator(mode="after")
165 def valid_payload(self):
166 if self.schema_version != TOOL_SCHEMA_VERSION:
167 raise ValueError("unsupported schema version")
168 if not self.source_url.startswith(("https://", "http://")):
169 raise ValueError("source URL must use HTTP(S)")
170 if self.price is not None and (not self.price.is_finite() or self.price <= 0):
171 raise ValueError("price must be positive and finite")
172 return self
173
174 def wire(self) -> dict[str, Any]:
175 return self.model_dump(mode="json", by_alias=True, exclude_none=True)