| 1 | from __future__ import annotations |
| 2 | |
| 3 | from datetime import date, datetime, timezone |
| 4 | from decimal import Decimal |
| 5 | from enum import StrEnum |
| 6 | from typing import Any |
| 7 | from uuid import UUID, uuid4 |
| 8 | |
| 9 | from pydantic import AnyHttpUrl, AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator |
| 10 | |
| 11 | |
| 12 | def _to_camel(value: str) -> str: |
| 13 | head, *tail = value.split("_") |
| 14 | return head + "".join(part.capitalize() for part in tail) |
| 15 | |
| 16 | |
| 17 | class ResearchBaseModel(BaseModel): |
| 18 | model_config = ConfigDict(alias_generator=_to_camel, populate_by_name=True, use_enum_values=True) |
| 19 | |
| 20 | |
| 21 | class SourceType(StrEnum): |
| 22 | COMPANY_WEBSITE = "COMPANY_WEBSITE" |
| 23 | INVESTOR_RELATIONS = "INVESTOR_RELATIONS" |
| 24 | EXCHANGE_ANNOUNCEMENT = "EXCHANGE_ANNOUNCEMENT" |
| 25 | REGULATORY_FILING = "REGULATORY_FILING" |
| 26 | GOVERNMENT_PROCUREMENT = "GOVERNMENT_PROCUREMENT" |
| 27 | RSS = "RSS" |
| 28 | NEWS = "NEWS" |
| 29 | SEARCH_DISCOVERY = "SEARCH_DISCOVERY" |
| 30 | |
| 31 | |
| 32 | class SourceClassification(StrEnum): |
| 33 | OFFICIAL_COMPANY = "OFFICIAL_COMPANY" |
| 34 | REGULATORY = "REGULATORY" |
| 35 | EXCHANGE = "EXCHANGE" |
| 36 | CUSTOMER = "CUSTOMER" |
| 37 | PARTNER = "PARTNER" |
| 38 | SUPPLIER = "SUPPLIER" |
| 39 | REPUTABLE_NEWS = "REPUTABLE_NEWS" |
| 40 | INVESTMENT_RESEARCH = "INVESTMENT_RESEARCH" |
| 41 | OTHER = "OTHER" |
| 42 | |
| 43 | |
| 44 | class FetchStrategy(StrEnum): |
| 45 | HTTP = "HTTP" |
| 46 | PLAYWRIGHT = "PLAYWRIGHT" |
| 47 | MANUAL = "MANUAL" |
| 48 | |
| 49 | |
| 50 | class SourceAccessStatus(StrEnum): |
| 51 | AVAILABLE = "AVAILABLE" |
| 52 | UNAVAILABLE = "UNAVAILABLE" |
| 53 | RESTRICTED = "RESTRICTED" |
| 54 | MANUAL_ONLY = "MANUAL_ONLY" |
| 55 | |
| 56 | |
| 57 | class SourceMode(StrEnum): |
| 58 | DEMO = "DEMO" |
| 59 | REAL = "REAL" |
| 60 | |
| 61 | |
| 62 | class ReliabilityLevel(StrEnum): |
| 63 | LEVEL_A = "LEVEL_A" |
| 64 | LEVEL_B = "LEVEL_B" |
| 65 | LEVEL_C = "LEVEL_C" |
| 66 | LEVEL_D = "LEVEL_D" |
| 67 | LEVEL_E = "LEVEL_E" |
| 68 | |
| 69 | |
| 70 | class DocumentStatus(StrEnum): |
| 71 | DISCOVERED = "DISCOVERED" |
| 72 | FETCHED = "FETCHED" |
| 73 | PARSED = "PARSED" |
| 74 | DUPLICATE = "DUPLICATE" |
| 75 | REJECTED = "REJECTED" |
| 76 | FAILED = "FAILED" |
| 77 | PROCESSED = "PROCESSED" |
| 78 | |
| 79 | |
| 80 | class DocumentType(StrEnum): |
| 81 | HTML = "HTML" |
| 82 | TEXT = "TEXT" |
| 83 | RSS_XML = "RSS_XML" |
| 84 | PDF_REFERENCE = "PDF_REFERENCE" |
| 85 | UNKNOWN = "UNKNOWN" |
| 86 | |
| 87 | |
| 88 | class DocumentSubtype(StrEnum): |
| 89 | INVESTOR_PRESENTATION = "INVESTOR_PRESENTATION" |
| 90 | INVESTOR_RELEASE = "INVESTOR_RELEASE" |
| 91 | CONFERENCE_CALL_MATERIAL = "CONFERENCE_CALL_MATERIAL" |
| 92 | ORDER_CONTRACT_DISCLOSURE = "ORDER_CONTRACT_DISCLOSURE" |
| 93 | CAPEX_CAPACITY_DISCLOSURE = "CAPEX_CAPACITY_DISCLOSURE" |
| 94 | |
| 95 | |
| 96 | class ShareholdingCategory(StrEnum): |
| 97 | PROMOTER = "PROMOTER" |
| 98 | PROMOTER_PLEDGE = "PROMOTER_PLEDGE" |
| 99 | FII_FPI = "FII_FPI" |
| 100 | DII = "DII" |
| 101 | MUTUAL_FUNDS = "MUTUAL_FUNDS" |
| 102 | INSURANCE = "INSURANCE" |
| 103 | GOVERNMENT = "GOVERNMENT" |
| 104 | PUBLIC_RETAIL = "PUBLIC_RETAIL" |
| 105 | OTHERS = "OTHERS" |
| 106 | |
| 107 | |
| 108 | class PledgeMetricBasis(StrEnum): |
| 109 | PERCENT_OF_PROMOTER_HOLDING = "PERCENT_OF_PROMOTER_HOLDING" |
| 110 | PERCENT_OF_TOTAL_SHARES = "PERCENT_OF_TOTAL_SHARES" |
| 111 | OTHER_EXPLICIT_SOURCE_BASIS = "OTHER_EXPLICIT_SOURCE_BASIS" |
| 112 | |
| 113 | |
| 114 | class ResearchEventType(StrEnum): |
| 115 | ORDER_WIN = "ORDER_WIN" |
| 116 | NEW_CONTRACT = "NEW_CONTRACT" |
| 117 | CLIENT_WIN = "CLIENT_WIN" |
| 118 | NEW_PLANT = "NEW_PLANT" |
| 119 | CREDIT_RATING = "CREDIT_RATING" |
| 120 | BORROWING_CHANGE = "BORROWING_CHANGE" |
| 121 | MANAGEMENT_GUIDANCE = "MANAGEMENT_GUIDANCE" |
| 122 | MAJOR_CORPORATE_ANNOUNCEMENT = "MAJOR_CORPORATE_ANNOUNCEMENT" |
| 123 | NEW_ORDER = "NEW_ORDER" |
| 124 | ORDER_BACKLOG_CHANGE = "ORDER_BACKLOG_CHANGE" |
| 125 | NEW_CUSTOMER = "NEW_CUSTOMER" |
| 126 | CUSTOMER_EXPANSION = "CUSTOMER_EXPANSION" |
| 127 | MAJOR_CUSTOMER = "MAJOR_CUSTOMER" |
| 128 | CUSTOMER_LOSS = "CUSTOMER_LOSS" |
| 129 | MAJOR_CONTRACT = "MAJOR_CONTRACT" |
| 130 | GOVERNMENT_CONTRACT = "GOVERNMENT_CONTRACT" |
| 131 | CAPEX = "CAPEX" |
| 132 | FACTORY_EXPANSION = "FACTORY_EXPANSION" |
| 133 | CAPACITY_EXPANSION = "CAPACITY_EXPANSION" |
| 134 | NEW_FACILITY = "NEW_FACILITY" |
| 135 | GEOGRAPHIC_EXPANSION = "GEOGRAPHIC_EXPANSION" |
| 136 | ACQUISITION = "ACQUISITION" |
| 137 | PARTNERSHIP = "PARTNERSHIP" |
| 138 | PRODUCT_LAUNCH = "PRODUCT_LAUNCH" |
| 139 | GUIDANCE_RAISED = "GUIDANCE_RAISED" |
| 140 | GUIDANCE_LOWERED = "GUIDANCE_LOWERED" |
| 141 | GUIDANCE_MAINTAINED = "GUIDANCE_MAINTAINED" |
| 142 | GUIDANCE_CUT = "GUIDANCE_CUT" |
| 143 | REVENUE_GUIDANCE = "REVENUE_GUIDANCE" |
| 144 | MARGIN_GUIDANCE = "MARGIN_GUIDANCE" |
| 145 | INVESTMENT = "INVESTMENT" |
| 146 | ORDER_CANCELLED = "ORDER_CANCELLED" |
| 147 | PROJECT_DELAY = "PROJECT_DELAY" |
| 148 | DEBT_CHANGE = "DEBT_CHANGE" |
| 149 | FUNDING = "FUNDING" |
| 150 | MANAGEMENT_CHANGE = "MANAGEMENT_CHANGE" |
| 151 | REGULATORY_EVENT = "REGULATORY_EVENT" |
| 152 | EARNINGS_RELEASE = "EARNINGS_RELEASE" |
| 153 | ANNUAL_REPORT = "ANNUAL_REPORT" |
| 154 | OTHER = "OTHER" |
| 155 | |
| 156 | |
| 157 | class EventImpact(StrEnum): |
| 158 | STRONG_POSITIVE = "STRONG_POSITIVE" |
| 159 | POSITIVE = "POSITIVE" |
| 160 | NEUTRAL = "NEUTRAL" |
| 161 | NEGATIVE = "NEGATIVE" |
| 162 | STRONG_NEGATIVE = "STRONG_NEGATIVE" |
| 163 | UNCERTAIN = "UNCERTAIN" |
| 164 | |
| 165 | |
| 166 | class TimeHorizon(StrEnum): |
| 167 | IMMEDIATE = "IMMEDIATE" |
| 168 | SHORT_TERM = "SHORT_TERM" |
| 169 | MEDIUM_TERM = "MEDIUM_TERM" |
| 170 | LONG_TERM = "LONG_TERM" |
| 171 | UNKNOWN = "UNKNOWN" |
| 172 | |
| 173 | |
| 174 | class ResearchLifecycleStatus(StrEnum): |
| 175 | DETECTED = "DETECTED" |
| 176 | VALIDATED = "VALIDATED" |
| 177 | REJECTED = "REJECTED" |
| 178 | |
| 179 | |
| 180 | class EvidenceState(StrEnum): |
| 181 | POSITIVE_EVIDENCE = "POSITIVE_EVIDENCE" |
| 182 | NEUTRAL_EVIDENCE = "NEUTRAL_EVIDENCE" |
| 183 | NEGATIVE_EVIDENCE = "NEGATIVE_EVIDENCE" |
| 184 | MIXED_EVIDENCE = "MIXED_EVIDENCE" |
| 185 | NO_EVIDENCE = "NO_EVIDENCE" |
| 186 | |
| 187 | |
| 188 | class SourceRateLimitPolicy(ResearchBaseModel): |
| 189 | requests_per_minute: int = 6 |
| 190 | min_delay_seconds: float = 10.0 |
| 191 | |
| 192 | |
| 193 | class ResearchSourceProvider(ResearchBaseModel): |
| 194 | source_id: str |
| 195 | source_type: SourceType |
| 196 | source_name: str |
| 197 | supported_countries: list[str] = Field(default_factory=list) |
| 198 | supported_markets: list[str] = Field(default_factory=list) |
| 199 | fetch_strategy: FetchStrategy |
| 200 | reliability_level: ReliabilityLevel |
| 201 | rate_limit_policy: SourceRateLimitPolicy = Field(default_factory=SourceRateLimitPolicy) |
| 202 | javascript_required: bool = False |
| 203 | automatic_access: SourceAccessStatus = SourceAccessStatus.MANUAL_ONLY |
| 204 | base_url: AnyHttpUrl | None = None |
| 205 | |
| 206 | |
| 207 | class CompanyResearchProfile(ResearchBaseModel): |
| 208 | instrument_id: UUID |
| 209 | company_id: UUID |
| 210 | company_name: str |
| 211 | aliases: list[str] = Field(default_factory=list) |
| 212 | provider_instrument_ids: dict[str, str] = Field(default_factory=dict) |
| 213 | isin: str | None = None |
| 214 | ticker: str |
| 215 | exchange: str |
| 216 | mic: str |
| 217 | country: str |
| 218 | currency: str |
| 219 | known_domains: list[str] = Field(default_factory=list) |
| 220 | official_website: AnyHttpUrl | None = None |
| 221 | investor_relations_url: AnyHttpUrl | None = None |
| 222 | press_release_url: AnyHttpUrl | None = None |
| 223 | annual_reports_url: AnyHttpUrl | None = None |
| 224 | exchange_announcements_url: AnyHttpUrl | None = None |
| 225 | regulatory_filings_url: AnyHttpUrl | None = None |
| 226 | rss_feeds: list[AnyHttpUrl] = Field(default_factory=list) |
| 227 | |
| 228 | |
| 229 | class ProvenancedValue(ResearchBaseModel): |
| 230 | value: Any |
| 231 | unit: str | None = None |
| 232 | as_of_date: datetime | None = None |
| 233 | period: str | None = None |
| 234 | source_url: str |
| 235 | source_name: str |
| 236 | source_type: str | None = None |
| 237 | published_at: datetime | None = None |
| 238 | retrieved_at: datetime |
| 239 | confidence: float | None = Field(default=None, ge=0.0, le=1.0) |
| 240 | calculation_basis: str | None = None |
| 241 | |
| 242 | |
| 243 | class StructuredInstrumentResolution(ResearchBaseModel): |
| 244 | instrument_id: UUID | None = None |
| 245 | provider: str |
| 246 | provider_ticker: str |
| 247 | company_name: str |
| 248 | exchange: str | None = None |
| 249 | currency: str | None = None |
| 250 | quote_type: str | None = None |
| 251 | confidence: float = Field(ge=0.0, le=1.0) |
| 252 | resolved_at: datetime |
| 253 | status: str = "RESOLVED" |
| 254 | |
| 255 | |
| 256 | class StructuredMarketSnapshot(ResearchBaseModel): |
| 257 | resolution: StructuredInstrumentResolution |
| 258 | status: str |
| 259 | retrieved_at: datetime |
| 260 | market_as_of: datetime | None = None |
| 261 | source_name: str = "Yahoo Finance" |
| 262 | source_type: str = "STRUCTURED_MARKET_PROVIDER" |
| 263 | source_url: str |
| 264 | facts: dict[str, ProvenancedValue] = Field(default_factory=dict) |
| 265 | statement_facts: list[dict[str, Any]] = Field(default_factory=list) |
| 266 | news: list[dict[str, Any]] = Field(default_factory=list) |
| 267 | accepted_fields_count: int = 0 |
| 268 | safe_error_code: str | None = None |
| 269 | |
| 270 | |
| 271 | class StructuredMarketSnapshotRecord(ResearchBaseModel): |
| 272 | """Durable latest successful structured-market evidence; never a financial fact.""" |
| 273 | instrument_id: UUID |
| 274 | provider: str |
| 275 | provider_instrument_id: str | None = None |
| 276 | exchange: str | None = None |
| 277 | mic: str | None = None |
| 278 | currency: str | None = None |
| 279 | quote_type: str | None = None |
| 280 | source_url: str | None = None |
| 281 | source_name: str | None = None |
| 282 | source_type: str | None = None |
| 283 | source_identity: str | None = None |
| 284 | market_as_of: datetime | None = None |
| 285 | retrieved_at: datetime |
| 286 | persisted_at: datetime |
| 287 | last_price_at: datetime | None = None |
| 288 | last_valuation_at: datetime | None = None |
| 289 | last_fundamentals_at: datetime | None = None |
| 290 | last_analyst_at: datetime | None = None |
| 291 | last_success_at: datetime | None = None |
| 292 | last_provider_attempt_at: datetime | None = None |
| 293 | acquisition_status: str = "SUCCESS" |
| 294 | last_failure_code: str | None = None |
| 295 | last_failure_message: str | None = None |
| 296 | snapshot: StructuredMarketSnapshot |
| 297 | |
| 298 | |
| 299 | class MarketPriceObservation(ResearchBaseModel): |
| 300 | """One durable, provider-neutral observed market price for an instrument.""" |
| 301 | instrument_id: UUID |
| 302 | observed_at: datetime |
| 303 | price: Decimal |
| 304 | currency: str | None = None |
| 305 | provider: str |
| 306 | source_url: str |
| 307 | retrieved_at: datetime |
| 308 | |
| 309 | |
| 310 | class DailyMarketBar(ResearchBaseModel): |
| 311 | """Public provider/day OHLCV evidence, separate from close-only history. |
| 312 | |
| 313 | Decimal bounds match PostgreSQL NUMERIC(38,12); extra precision is rejected, |
| 314 | never silently rounded. Turnover is stored as supplied, without unit conversion. |
| 315 | Provider symbol is provenance, not canonical identity. Optional metrics may |
| 316 | remain missing, including all prices when only volume evidence is supplied. |
| 317 | """ |
| 318 | global_instrument_id: UUID |
| 319 | trading_date: date |
| 320 | open: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 321 | high: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 322 | low: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 323 | close: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 324 | previous_close: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 325 | volume: int | None = Field(default=None, strict=True, ge=0, le=9223372036854775807) |
| 326 | turnover: Decimal | None = Field(default=None, ge=0, max_digits=38, decimal_places=12, allow_inf_nan=False) |
| 327 | currency: str = Field(min_length=1, max_length=16) |
| 328 | provider: str = Field(min_length=1, max_length=120) |
| 329 | provider_symbol: str | None = Field(default=None, max_length=240) |
| 330 | source_mode: SourceMode |
| 331 | source_url: str = Field(min_length=1, max_length=1000) |
| 332 | retrieved_at: AwareDatetime |
| 333 | |
| 334 | @field_validator("currency", "provider", "source_url", mode="before") |
| 335 | @classmethod |
| 336 | def strip_bar_metadata(cls, value): |
| 337 | return value.strip() if isinstance(value, str) else value |
| 338 | |
| 339 | @field_validator("trading_date", mode="before") |
| 340 | @classmethod |
| 341 | def require_bar_date(cls, value): |
| 342 | if type(value) is date: |
| 343 | return value |
| 344 | if isinstance(value, str) and len(value) == 10: |
| 345 | return date.fromisoformat(value) |
| 346 | raise ValueError("tradingDate must be a DATE, not a timestamp") |
| 347 | |
| 348 | @field_validator("retrieved_at") |
| 349 | @classmethod |
| 350 | def normalize_bar_retrieved_at(cls, value): |
| 351 | return value.astimezone(timezone.utc) |
| 352 | |
| 353 | @model_validator(mode="after") |
| 354 | def validate_bar_range(self): |
| 355 | if self.high is not None and self.low is not None and self.high < self.low: |
| 356 | raise ValueError("daily bar high must be >= low") |
| 357 | return self |
| 358 | |
| 359 | |
| 360 | class PublicAnalyst(ResearchBaseModel): |
| 361 | target_low_price: Decimal | None = None |
| 362 | target_median_price: Decimal | None = None |
| 363 | target_mean_price: Decimal | None = None |
| 364 | target_high_price: Decimal | None = None |
| 365 | analyst_count: int | None = None |
| 366 | recommendation_mean: Decimal | None = None |
| 367 | consensus: str | None = None |
| 368 | currency: str | None = None |
| 369 | provider: str |
| 370 | provider_instrument_id: str | None = None |
| 371 | source_name: str | None = None |
| 372 | source_url: str | None = None |
| 373 | as_of: datetime | None = None |
| 374 | retrieved_at: datetime | None = None |
| 375 | freshness: str |
| 376 | |
| 377 | |
| 378 | class MarketFundamentals(ResearchBaseModel): |
| 379 | market_cap: Decimal | None = None |
| 380 | enterprise_value: Decimal | None = None |
| 381 | trailing_pe: Decimal | None = None |
| 382 | forward_pe: Decimal | None = None |
| 383 | price_to_book: Decimal | None = None |
| 384 | price_to_sales: Decimal | None = None |
| 385 | ev_to_revenue: Decimal | None = None |
| 386 | ev_to_ebitda: Decimal | None = None |
| 387 | peg_ratio: Decimal | None = None |
| 388 | trailing_eps: Decimal | None = None |
| 389 | forward_eps: Decimal | None = None |
| 390 | book_value_per_share: Decimal | None = None |
| 391 | roe: Decimal | None = None |
| 392 | roa: Decimal | None = None |
| 393 | debt_to_equity: Decimal | None = None |
| 394 | profit_margin: Decimal | None = None |
| 395 | operating_margin: Decimal | None = None |
| 396 | revenue_growth: Decimal | None = None |
| 397 | earnings_growth: Decimal | None = None |
| 398 | total_cash: Decimal | None = None |
| 399 | total_debt: Decimal | None = None |
| 400 | free_cash_flow: Decimal | None = None |
| 401 | operating_cash_flow: Decimal | None = None |
| 402 | provider: str |
| 403 | provider_instrument_id: str | None = None |
| 404 | source_name: str | None = None |
| 405 | source_url: str | None = None |
| 406 | as_of: datetime | None = None |
| 407 | retrieved_at: datetime | None = None |
| 408 | freshness: str |
| 409 | metric_semantics: dict[str, str] = Field(default_factory=dict) |
| 410 | |
| 411 | |
| 412 | class QuarterlyResult(ResearchBaseModel): |
| 413 | period: str |
| 414 | document_title: str | None = None |
| 415 | extraction_status: str = "EXTRACTED" |
| 416 | reporting_basis: str | None = None |
| 417 | result_date: datetime | None = None |
| 418 | revenue: ProvenancedValue | None = None |
| 419 | revenue_yoy_percent: ProvenancedValue | None = None |
| 420 | revenue_qoq_percent: ProvenancedValue | None = None |
| 421 | ebitda: ProvenancedValue | None = None |
| 422 | ebitda_margin: ProvenancedValue | None = None |
| 423 | ebitda_yoy_percent: ProvenancedValue | None = None |
| 424 | pat: ProvenancedValue | None = None |
| 425 | pat_yoy_percent: ProvenancedValue | None = None |
| 426 | pat_qoq_percent: ProvenancedValue | None = None |
| 427 | eps: ProvenancedValue | None = None |
| 428 | debt_or_borrowings: ProvenancedValue | None = None |
| 429 | exceptional_items: str | None = None |
| 430 | segment_information: str | None = None |
| 431 | management_commentary: list[str] = Field(default_factory=list) |
| 432 | yoy_summary: str | None = None |
| 433 | nim: ProvenancedValue | None = None |
| 434 | roa: ProvenancedValue | None = None |
| 435 | roe: ProvenancedValue | None = None |
| 436 | gross_npa: ProvenancedValue | None = None |
| 437 | net_npa: ProvenancedValue | None = None |
| 438 | deposits: ProvenancedValue | None = None |
| 439 | advances: ProvenancedValue | None = None |
| 440 | capital_adequacy: ProvenancedValue | None = None |
| 441 | credit_cost: ProvenancedValue | None = None |
| 442 | source_name: str |
| 443 | source_url: str |
| 444 | source_type: str |
| 445 | published_at: datetime | None = None |
| 446 | retrieved_at: datetime |
| 447 | confidence: float = Field(ge=0.0, le=1.0) |
| 448 | |
| 449 | |
| 450 | class FinancialResultPeriod(ResearchBaseModel): |
| 451 | period: str |
| 452 | period_type: str |
| 453 | reporting_basis: str | None = None |
| 454 | revenue: ProvenancedValue | None = None |
| 455 | operating_income: ProvenancedValue | None = None |
| 456 | ebit: ProvenancedValue | None = None |
| 457 | ebitda: ProvenancedValue | None = None |
| 458 | pat: ProvenancedValue | None = None |
| 459 | eps: ProvenancedValue | None = None |
| 460 | source_name: str |
| 461 | source_url: str |
| 462 | source_type: str |
| 463 | published_at: datetime | None = None |
| 464 | retrieved_at: datetime |
| 465 | confidence: float = Field(ge=0.0, le=1.0) |
| 466 | |
| 467 | |
| 468 | class FinancialStatementPeriod(ResearchBaseModel): |
| 469 | """One persisted, source-backed balance-sheet or cash-flow period.""" |
| 470 | period: str |
| 471 | period_type: str |
| 472 | reporting_basis: str | None = None |
| 473 | metrics: dict[str, ProvenancedValue] = Field(default_factory=dict) |
| 474 | |
| 475 | |
| 476 | class ShareholdingChange(ResearchBaseModel): |
| 477 | category: str |
| 478 | current: ProvenancedValue |
| 479 | previous: ProvenancedValue |
| 480 | current_period: str |
| 481 | previous_period: str |
| 482 | change_percentage_points: Decimal |
| 483 | source_date: datetime | None = None |
| 484 | |
| 485 | |
| 486 | class ShareholdingSnapshotValue(ResearchBaseModel): |
| 487 | id: UUID = Field(default_factory=uuid4) |
| 488 | category: ShareholdingCategory |
| 489 | percentage: Decimal = Field(ge=Decimal("0"), le=Decimal("100")) |
| 490 | metric_basis: str | None = None |
| 491 | raw_source_label: str | None = None |
| 492 | source_locator: str | None = None |
| 493 | evidence_text: str | None = None |
| 494 | created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 495 | |
| 496 | |
| 497 | class ShareholdingSnapshot(ResearchBaseModel): |
| 498 | id: UUID = Field(default_factory=uuid4) |
| 499 | instrument_id: UUID |
| 500 | period_end: datetime |
| 501 | filing_basis: str | None = None |
| 502 | source_provider: str |
| 503 | source_type: str |
| 504 | source_identity_key: str |
| 505 | source_url: str |
| 506 | research_document_id: UUID | None = None |
| 507 | published_at: datetime | None = None |
| 508 | retrieved_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 509 | confidence: Decimal = Field(ge=Decimal("0"), le=Decimal("1")) |
| 510 | reliability_level: ReliabilityLevel |
| 511 | source_mode: SourceMode |
| 512 | created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 513 | updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 514 | values: list[ShareholdingSnapshotValue] = Field(default_factory=list) |
| 515 | |
| 516 | @field_validator("values") |
| 517 | @classmethod |
| 518 | def pledge_requires_explicit_basis(cls, values: list[ShareholdingSnapshotValue]) -> list[ShareholdingSnapshotValue]: |
| 519 | for value in values: |
| 520 | if value.category == ShareholdingCategory.PROMOTER_PLEDGE and not value.metric_basis: |
| 521 | raise ValueError("PROMOTER_PLEDGE requires an explicit metric basis") |
| 522 | return values |
| 523 | |
| 524 | |
| 525 | class ValuationAssessment(ResearchBaseModel): |
| 526 | state: str = "UNKNOWN" |
| 527 | reason: str |
| 528 | current_pe: ProvenancedValue | None = None |
| 529 | sector_pe: ProvenancedValue | None = None |
| 530 | peer_pe: ProvenancedValue | None = None |
| 531 | historical_pe: ProvenancedValue | None = None |
| 532 | roe: ProvenancedValue | None = None |
| 533 | roce: ProvenancedValue | None = None |
| 534 | state_evidence: "ValuationStateEvidence | None" = None |
| 535 | |
| 536 | |
| 537 | class ValuationBenchmark(ResearchBaseModel): |
| 538 | kind: str |
| 539 | value: ProvenancedValue |
| 540 | |
| 541 | |
| 542 | class ValuationStateEvidence(ResearchBaseModel): |
| 543 | primary_metric: str |
| 544 | current_value: ProvenancedValue |
| 545 | benchmarks: list[ValuationBenchmark] |
| 546 | benchmark_value: Decimal |
| 547 | comparison_ratio: Decimal |
| 548 | comparison_method: str |
| 549 | explanation: str |
| 550 | |
| 551 | |
| 552 | class SourceDiversity(ResearchBaseModel): |
| 553 | sources_found: int = 0 |
| 554 | domains_found: int = 0 |
| 555 | official_sources: int = 0 |
| 556 | exchange_sources: int = 0 |
| 557 | company_sources: int = 0 |
| 558 | secondary_sources: int = 0 |
| 559 | |
| 560 | |
| 561 | class EtfResearchProfile(ResearchBaseModel): |
| 562 | instrument_id: UUID |
| 563 | fund_id: UUID |
| 564 | fund_name: str |
| 565 | ticker: str |
| 566 | exchange: str |
| 567 | mic: str |
| 568 | provider: str | None = None |
| 569 | provider_instrument_id: str | None = None |
| 570 | isin: str | None = None |
| 571 | currency: str | None = None |
| 572 | fund_provider: str | None = None |
| 573 | underlying_index: str | None = None |
| 574 | known_domains: list[str] = Field(default_factory=list) |
| 575 | facts: dict[str, ProvenancedValue] = Field(default_factory=dict) |
| 576 | |
| 577 | |
| 578 | class EntityResolution(ResearchBaseModel): |
| 579 | instrument_id: UUID | None |
| 580 | company_id: UUID | None |
| 581 | confidence: float = Field(ge=0.0, le=1.0) |
| 582 | matched_on: list[str] = Field(default_factory=list) |
| 583 | |
| 584 | |
| 585 | class ResearchDocument(ResearchBaseModel): |
| 586 | document_id: UUID = Field(default_factory=uuid4) |
| 587 | canonical_url: str |
| 588 | original_url: str |
| 589 | title: str | None = None |
| 590 | source_type: SourceType |
| 591 | source_classification: SourceClassification = SourceClassification.OTHER |
| 592 | source_name: str |
| 593 | publisher: str | None = None |
| 594 | published_at: datetime | None = None |
| 595 | retrieved_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 596 | language: str | None = None |
| 597 | content_type: str |
| 598 | document_type: DocumentType |
| 599 | document_subtype: DocumentSubtype | None = None |
| 600 | raw_text: str | None = Field(default=None, exclude=True) |
| 601 | normalized_text: str | None = Field(default=None, exclude=True) |
| 602 | content_hash: str |
| 603 | instrument_id: UUID | None = None |
| 604 | company_id: UUID | None = None |
| 605 | country: str | None = None |
| 606 | exchange: str | None = None |
| 607 | status: DocumentStatus = DocumentStatus.DISCOVERED |
| 608 | reliability_level: ReliabilityLevel |
| 609 | entity_resolution_confidence: float = Field(default=0.0, ge=0.0, le=1.0) |
| 610 | source_mode: SourceMode = SourceMode.DEMO |
| 611 | freshness: str = "DEMO" |
| 612 | discovered_at: datetime | None = None |
| 613 | discovery_provider: str | None = None |
| 614 | source_independence_key: str | None = None |
| 615 | duplicate_of_document_id: UUID | None = None |
| 616 | |
| 617 | |
| 618 | class ResearchEvidenceSource(ResearchBaseModel): |
| 619 | publisher: str | None = None |
| 620 | url: str |
| 621 | source_type: SourceClassification |
| 622 | published_at: datetime | None = None |
| 623 | retrieved_at: datetime |
| 624 | reliability: ReliabilityLevel |
| 625 | source_mode: SourceMode |
| 626 | document_id: UUID |
| 627 | source_name: str |
| 628 | canonical_url: str |
| 629 | independent: bool = True |
| 630 | |
| 631 | |
| 632 | class NormalizedNumber(ResearchBaseModel): |
| 633 | original: str |
| 634 | value: Decimal |
| 635 | unit: str | None = None |
| 636 | currency: str | None = None |
| 637 | |
| 638 | |
| 639 | class ResearchEvent(ResearchBaseModel): |
| 640 | event_id: UUID = Field(default_factory=uuid4) |
| 641 | instrument_id: UUID |
| 642 | company_id: UUID |
| 643 | event_type: ResearchEventType |
| 644 | event_date: datetime | None = None |
| 645 | detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 646 | title: str |
| 647 | summary: str |
| 648 | source_document_id: UUID |
| 649 | source_url: str |
| 650 | source_type: SourceType |
| 651 | source_classification: SourceClassification = SourceClassification.OTHER |
| 652 | reliability: ReliabilityLevel |
| 653 | source_mode: SourceMode = SourceMode.DEMO |
| 654 | confidence: float = Field(ge=0.0, le=1.0) |
| 655 | impact: EventImpact |
| 656 | time_horizon: TimeHorizon |
| 657 | currency: str | None = None |
| 658 | monetary_value: Decimal | None = None |
| 659 | monetary_original: str | None = None |
| 660 | percentage_value: Decimal | None = None |
| 661 | percentage_original: str | None = None |
| 662 | customer: str | None = None |
| 663 | counterparty: str | None = None |
| 664 | location: str | None = None |
| 665 | capacity_value: Decimal | None = None |
| 666 | capacity_unit: str | None = None |
| 667 | status: ResearchLifecycleStatus = ResearchLifecycleStatus.VALIDATED |
| 668 | raw_evidence_reference: str |
| 669 | published_at: datetime | None = None |
| 670 | retrieved_at: datetime | None = None |
| 671 | supporting_sources: list[ResearchEvidenceSource] = Field(default_factory=list) |
| 672 | independence_key: str | None = None |
| 673 | |
| 674 | @field_validator("raw_evidence_reference") |
| 675 | @classmethod |
| 676 | def limit_evidence(cls, value: str) -> str: |
| 677 | return value[:500] |
| 678 | |
| 679 | |
| 680 | class CategoryEvidence(ResearchBaseModel): |
| 681 | category: str |
| 682 | status: EvidenceState |
| 683 | score: int | None = Field(default=None, ge=0, le=100) |
| 684 | event_count: int = 0 |
| 685 | source_count: int = 0 |
| 686 | independent_source_count: int = 0 |
| 687 | has_conflict: bool = False |
| 688 | # Category-linked evidence is deliberately separate from the compact |
| 689 | # ``recent_events`` list. A scored category must remain explainable even |
| 690 | # when its event falls outside that general-purpose top-ten slice. |
| 691 | supporting_events: list[ResearchEvent] = Field(default_factory=list) |
| 692 | |
| 693 | |
| 694 | class CatalystScore(ResearchBaseModel): |
| 695 | instrument_id: UUID |
| 696 | overall_score: int = Field(ge=0, le=100) |
| 697 | buckets: dict[str, int | None] |
| 698 | category_evidence: dict[str, CategoryEvidence] = Field(default_factory=dict) |
| 699 | aggregation_rule: str = "Overall score is calculated from validated evidence events only; NO_EVIDENCE categories are omitted and are not treated as score 50." |
| 700 | research_confidence: int = Field(ge=0, le=100) |
| 701 | generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 702 | |
| 703 | |
| 704 | class ResearchSummary(ResearchBaseModel): |
| 705 | profile: CompanyResearchProfile |
| 706 | catalyst_score: CatalystScore |
| 707 | recent_events: list[ResearchEvent] |
| 708 | documents: list[ResearchDocument] |
| 709 | last_refresh_at: datetime | None = None |
| 710 | data_freshness: str |
| 711 | demo: bool |
| 712 | source_mix: dict[str, int] |
| 713 | shareholding_snapshots: list[ShareholdingSnapshot] = Field(default_factory=list) |
| 714 | shareholding_freshness: str = "UNAVAILABLE" |
| 715 | latest_quarterly_result: QuarterlyResult | None = None |
| 716 | financial_result_history: list[FinancialResultPeriod] = Field(default_factory=list) |
| 717 | balance_sheet_history: list[FinancialStatementPeriod] = Field(default_factory=list) |
| 718 | cash_flow_history: list[FinancialStatementPeriod] = Field(default_factory=list) |
| 719 | |
| 720 | |
| 721 | class PortfolioResearchCompany(ResearchBaseModel): |
| 722 | instrument_id: UUID | None = None |
| 723 | company_id: UUID | None = None |
| 724 | company_name: str |
| 725 | ticker: str | None = None |
| 726 | exchange: str | None = None |
| 727 | isin: str | None = None |
| 728 | provider: str | None = None |
| 729 | provider_instrument_id: str | None = None |
| 730 | listing_provider: str | None = None |
| 731 | listing_symbol: str | None = None |
| 732 | primary_exchange: str | None = None |
| 733 | verified_provider_mappings: dict[str, str] = Field(default_factory=dict) |
| 734 | asset_type: str | None = None |
| 735 | status: str |
| 736 | catalyst_score: int | None = None |
| 737 | confidence: int | None = None |
| 738 | evidence_coverage: dict[str, str] = Field(default_factory=dict) |
| 739 | durable_category_evidence: dict[str, CategoryEvidence] = Field(default_factory=dict) |
| 740 | latest_event: ResearchEvent | None = None |
| 741 | positive_events_count: int = 0 |
| 742 | negative_events_count: int = 0 |
| 743 | neutral_events_count: int = 0 |
| 744 | document_count: int = 0 |
| 745 | event_count: int = 0 |
| 746 | source_count: int = 0 |
| 747 | last_refresh: datetime | None = None |
| 748 | freshness: str = "UNAVAILABLE" |
| 749 | mode: str = "UNAVAILABLE" |
| 750 | missing_categories: list[str] = Field(default_factory=list) |
| 751 | etf_profile: EtfResearchProfile | None = None |
| 752 | current_price: Decimal | None = None |
| 753 | entry_zone_low: Decimal | None = None |
| 754 | entry_zone_high: Decimal | None = None |
| 755 | target1: Decimal | None = None |
| 756 | target2: Decimal | None = None |
| 757 | risk_invalidation_level: Decimal | None = None |
| 758 | potential_upside_pct: Decimal | None = None |
| 759 | potential_downside_pct: Decimal | None = None |
| 760 | risk_reward_ratio: Decimal | None = None |
| 761 | safe_error_code: str | None = None |
| 762 | safe_error_message: str | None = None |
| 763 | latest_quarterly_result: QuarterlyResult | None = None |
| 764 | financial_result_history: list[FinancialResultPeriod] = Field(default_factory=list) |
| 765 | balance_sheet_history: list[FinancialStatementPeriod] = Field(default_factory=list) |
| 766 | cash_flow_history: list[FinancialStatementPeriod] = Field(default_factory=list) |
| 767 | quarterly_result_status: str = "NOT_AVAILABLE" |
| 768 | shareholding_changes: list[ShareholdingChange] = Field(default_factory=list) |
| 769 | shareholding_snapshots: list[ShareholdingSnapshot] = Field(default_factory=list) |
| 770 | shareholding_freshness: str = "UNAVAILABLE" |
| 771 | ownership_increases: list[str] = Field(default_factory=list) |
| 772 | valuation: ValuationAssessment = Field(default_factory=lambda: ValuationAssessment( |
| 773 | state="UNKNOWN", reason="Insufficient comparable public valuation evidence." |
| 774 | )) |
| 775 | current_quarter_catalysts: list[ResearchEvent] = Field(default_factory=list) |
| 776 | source_diversity: SourceDiversity = Field(default_factory=SourceDiversity) |
| 777 | structured_market: StructuredMarketSnapshot | None = None |
| 778 | structured_provider_status: str | None = None |
| 779 | public_analyst: PublicAnalyst | None = None |
| 780 | market_fundamentals: MarketFundamentals | None = None |
| 781 | price_change: Decimal | None = None |
| 782 | price_change_percent: Decimal | None = None |
| 783 | price_direction: str = "UNKNOWN" |
| 784 | market_status: str = "UNKNOWN" |
| 785 | price_freshness: str = "NEVER_FETCHED" |
| 786 | market_as_of: datetime | None = None |
| 787 | |
| 788 | |
| 789 | class PortfolioResearchSummary(ResearchBaseModel): |
| 790 | portfolio_id: UUID |
| 791 | generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 792 | companies_requested: int = 0 |
| 793 | companies_resolved: int = 0 |
| 794 | companies_succeeded: int = 0 |
| 795 | companies_degraded: int = 0 |
| 796 | companies_failed: int = 0 |
| 797 | documents_created: int = 0 |
| 798 | events_created: int = 0 |
| 799 | deduplicated_count: int = 0 |
| 800 | companies: list[PortfolioResearchCompany] = Field(default_factory=list) |
| 801 | total_companies: int = 0 |
| 802 | completed: int = 0 |
| 803 | partial: int = 0 |
| 804 | failed: int = 0 |
| 805 | unsupported: int = 0 |
| 806 | in_progress: int = 0 |
| 807 | |
| 808 | |
| 809 | class PlatformEvent(ResearchBaseModel): |
| 810 | event_type: str |
| 811 | version: int = 1 |
| 812 | event_id: UUID = Field(default_factory=uuid4) |
| 813 | correlation_id: str | None = None |
| 814 | occurred_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) |
| 815 | payload: dict[str, Any] |