main
py 815 lines 28.2 KB
Raw
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]