crucible / engine /state.py
s-ttp's picture
Claim lineage: typed guidance/engine-vs-consensus relations, lineage checks, HistoricalMetricQuality label
bb286dd verified
Raw History Blame Contribute Delete
26.1 kB
"""AnalysisState: the one authoritative object for a run.
Every published concept has exactly one location here (``state.forecast.eps``,
``state.pricing.market_composite``, ``state.blend.headline_return`` …). Legacy dicts produced by
the older modules are raw inputs to adapters that write this object; no persona, validator,
summary generator, renderer, claim audit or recommendation mapper reads them.
Three guarantees are enforced by the object itself rather than by convention:
* **Typed metrics.** Every number is a ``Metric`` carrying concept, period class, accounting basis,
scope, currency, security basis, source, date, derivation and quality. Two metrics are only
compared through ``compatible()``; an incompatible pair raises instead of producing a number.
* **Stage order.** ``advance()`` moves through DATA_VALIDATED → FORECAST_VALIDATED →
ANALYSIS_FINALIZED → REPORT_VALIDATED → RENDERED, only on a passing validation result, and only
in order.
* **No mutation after validation.** Passing a stage seals the namespaces that stage validated.
Writing to a sealed namespace raises ``SealedStateError``; after ANALYSIS_FINALIZED the whole
state is frozen and carries a content version.
"""
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import asdict, dataclass, replace
from types import MappingProxyType
# ── period classes and bases ──────────────────────────────────────────────────────────
TTM, FY, NTM, FY1, FY2, SPOT, INTRINSIC, POINT = "TTM", "FY", "NTM", "FY1", "FY2", "SPOT", "INTRINSIC", "POINT"
TRAILING = frozenset({TTM, FY})
FORWARD = frozenset({NTM, FY1, FY2})
GAAP, IFRS, ADJUSTED, MODEL, MARKET, NA = "GAAP", "IFRS", "ADJUSTED", "MODEL", "MARKET", "NA"
REPORTED_BASES = frozenset({GAAP, IFRS})
ORDINARY_SHARE, ADS = "ORDINARY_SHARE", "ADS"
# quality states
VALIDATED, RECONCILED, UNVERIFIED, DERIVED, MODEL_ESTIMATE, WITHHELD, UNAVAILABLE = (
"VALIDATED", "RECONCILED", "UNVERIFIED", "DERIVED", "MODEL_ESTIMATE", "WITHHELD", "UNAVAILABLE")
PARTIALLY_RECONCILED = "PARTIALLY_RECONCILED" # some, not all, of the periods a trailing figure sums were checked
class IncompatibleMetrics(Exception):
"""Two metrics were compared that do not describe comparable quantities."""
class SealedStateError(Exception):
"""A namespace was written after the stage that validated it had passed."""
class StageOrderError(Exception):
"""A stage was entered before the stage it depends on had passed."""
@dataclass(frozen=True)
class Metric:
metric_id: str
economic_concept: str
value: float | None
period_type: str
accounting_basis: str = NA
currency: str | None = "USD"
scale: str = "UNIT"
period: str | None = None # period end (ISO date) or as-of date
scope: str = "CONSOLIDATED"
perimeter: str = "consolidated"
security_basis: str = "NA" # ORDINARY_SHARE / ADS for per-share values
per_share: bool = False
source: str = ""
source_date: str | None = None
derivation: str | None = None
quality_status: str = VALIDATED
unit: str = "USD" # USD, ratio, x, per_share, count, pct_points
# non-GAAP semantics: "ADJUSTED" alone is not a definition. Two adjusted figures compare only
# when their definitions match or an explicit reconciliation exists (definitions.py)
adjustment_definition_id: str | None = None # e.g. PROVIDER_REPORTED_EPS, SELL_SIDE_CONSENSUS_EPS
adjustment_provider: str | None = None
adjustment_version: str | None = None
excluded_items: tuple = ()
definition_effective_date: str | None = None
# the actual fiscal period, not just its class: NTM and FY1 are both "forward" but need not overlap
period_start: str | None = None
period_end: str | None = None
forecast_horizon_days: int | None = None
# what the number IS (quantities.py): a margin level and a margin change can share a decimal, never a type
quantity_type: str | None = None # LEVEL, GROWTH_RATE, MARGIN_LEVEL, MARGIN_CHANGE, …
display_unit: str | None = None # PERCENT, BASIS_POINTS, PERCENTAGE_POINTS, USD_PER_SHARE, …
# the corporate action that defines this figure's perimeter (corporate_actions.py); None = no perimeter event
corporate_action_id: str | None = None
@property
def period_class(self):
if self.period_type in FORWARD:
return "FORWARD"
if self.period_type in TRAILING:
return "TRAILING"
return self.period_type
@property
def basis_class(self):
if self.accounting_basis in REPORTED_BASES:
return "REPORTED"
return self.accounting_basis
def to_dict(self):
d = asdict(self)
d["period_class"] = self.period_class
return d
def with_value(self, value, **kw):
return replace(self, value=value, **kw)
def metric(metric_id, concept, value, period_type, **kw):
try:
v = None if value is None else float(value)
except (TypeError, ValueError):
v = None
if v is not None and v != v: # NaN
v = None
if "quantity_type" not in kw: # what the number IS follows from its concept (quantities.py)
import quantities as _q
kw["quantity_type"] = _q.CONCEPT_QUANTITY.get(concept) or _EXTRA_QUANTITY.get(concept)
return Metric(metric_id=metric_id, economic_concept=concept, value=v, period_type=period_type, **kw)
_EXTRA_QUANTITY = {"PRICE": "LEVEL", "MARKET_CAP": "LEVEL", "SHARES": "LEVEL", "DILUTED_SHARES": "LEVEL",
"DIVIDEND_YIELD": "YIELD", "ROE": "MARGIN_LEVEL", "ROA": "MARGIN_LEVEL", "PE": "LEVEL", "PEG": "LEVEL",
"INTRINSIC_VALUE": "LEVEL", "MARKET_PRICING": "LEVEL", "TARGET_PRICE": "LEVEL",
"PRETAX_INCOME": "LEVEL", "NET_INCOME": "LEVEL", "BOOK_EQUITY": "LEVEL", "EPS_GAP": "CHANGE",
"COST_OF_CAPITAL": "YIELD", "TERMINAL_GROWTH": "GROWTH_RATE", "TARGET_GAP": "CHANGE",
"BREAK_EVEN_REVENUE": "LEVEL", "BREAK_EVEN_OPERATING_MARGIN": "MARGIN_LEVEL",
"BREAK_EVEN_REVENUE_CAGR": "GROWTH_RATE", "MARKET_SIZE": "LEVEL", "YEARS": "LEVEL",
"MARKET_SHARE": "MARGIN_LEVEL"}
def stamp_perimeter(state, action_id, namespaces):
"""Attach the perimeter's corporate_action_id to every Metric on a separated perimeter (pre-separation or
continuing company), in the given namespaces, including Metrics nested one level in dicts."""
if not action_id:
return 0
n = 0
split = ("pre-separation", "continuing company")
def fix(v):
nonlocal n
if isinstance(v, Metric) and v.perimeter in split and v.corporate_action_id != action_id:
n += 1
return replace(v, corporate_action_id=action_id)
return v
for ns in namespaces:
space = getattr(state, ns, None)
if space is None:
continue
for k in list(space.keys()):
v = space.get(k)
if isinstance(v, Metric):
nv = fix(v)
if nv is not v:
space.set(k, nv)
elif isinstance(v, dict) and any(isinstance(x, Metric) for x in v.values()):
nd = {kk: fix(x) for kk, x in v.items()}
space.set(k, nd)
return n
# perimeter: which business the figure describes. A completed separation leaves the trailing
# statements on the old perimeter while consensus and the forecast describe the continuing company.
CONSOLIDATED, PRE_SEPARATION, CONTINUING = "consolidated", "pre-separation", "continuing company"
def _same_perimeter(a, b):
pa, pb = a.perimeter or CONSOLIDATED, b.perimeter or CONSOLIDATED
# "consolidated" (no separation recorded) and "continuing company" describe the same business today
norm = lambda p: CONTINUING if p == CONSOLIDATED else p
return norm(pa) == norm(pb)
# ── compatibility: the ONLY way two metrics may be compared ───────────────────────────
COMPARISON_TYPES = ("level", "margin_relationship", "growth", "ratio", "benchmark", "valuation", "trend", "scenario", "path")
# a scenario concept is the same economic quantity under a stated condition (what today's price requires): it compares
# with its base concept, never with another quantity
SCENARIO_BASE = {"BREAK_EVEN_REVENUE": "REVENUE", "BREAK_EVEN_OPERATING_MARGIN": "OPERATING_MARGIN",
"BREAK_EVEN_REVENUE_CAGR": "REVENUE_GROWTH"}
def base_concept(c):
return SCENARIO_BASE.get(c, c)
def compatible(a: Metric, b: Metric, comparison_type: str):
"""(ok, reasons). Mandatory before any comparison or derivation.
level same concept, same period class, same basis class, same scope, currency,
security basis — e.g. model EPS vs consensus EPS
margin_relationship different concepts of one income statement: same period class, same basis
class, same scope — e.g. forward net margin vs forward operating margin
growth same concept and basis; one trailing, one forward (a forecast vs its base)
ratio numerator and denominator of a multiple: same currency and security basis;
a SPOT price may divide a forward or trailing per-share figure
benchmark a company figure against an industry or peer aggregate of the SAME concept, period class
and basis class (scope and perimeter may differ: the benchmark is another universe)
valuation two per-share values (price, intrinsic value, composite, target): same currency and
security basis; concepts may differ
"""
if comparison_type not in COMPARISON_TYPES:
raise ValueError(f"unknown comparison type {comparison_type!r}")
r = []
if a is None or b is None or a.value is None or b.value is None:
return False, ["a value is missing"]
if comparison_type in ("scenario", "path"):
# scenario: a scenario figure against its base concept at the same horizon (break-even vs story revenue);
# path: two levels of one quantity at two points of one path (starting revenue → break-even revenue)
if base_concept(a.economic_concept) != base_concept(b.economic_concept):
r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}")
if a.quantity_type and b.quantity_type and a.quantity_type != b.quantity_type:
r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}")
if comparison_type == "scenario" and a.period_class != b.period_class:
r.append(f"period {a.period_class} ≠ {b.period_class}")
if (a.currency or "") != (b.currency or "") and a.unit not in ("ratio", "x") and b.unit not in ("ratio", "x"):
r.append(f"currency {a.currency} ≠ {b.currency}")
if not _same_perimeter(a, b):
r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}")
return (not r), r
if comparison_type in ("level", "growth", "benchmark") and a.economic_concept != b.economic_concept:
r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}")
if comparison_type in ("level", "growth", "benchmark") and a.quantity_type and b.quantity_type \
and a.quantity_type != b.quantity_type:
r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}")
if comparison_type == "benchmark":
if a.period_class != b.period_class:
r.append(f"period {a.period_class} ≠ {b.period_class}")
if MODEL not in (a.basis_class, b.basis_class) and a.basis_class != b.basis_class:
r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}")
return (not r), r
if comparison_type == "trend":
# the same quantity at two points in time (trailing vs forward): one concept, one basis, ONE business
if a.economic_concept != b.economic_concept:
r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}")
if a.quantity_type and b.quantity_type and a.quantity_type != b.quantity_type:
r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}")
if MODEL not in (a.basis_class, b.basis_class) and a.basis_class != b.basis_class:
r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}")
if a.scope != b.scope:
r.append(f"scope {a.scope} ≠ {b.scope}")
if not _same_perimeter(a, b):
r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}")
return (not r), r
if comparison_type == "valuation":
if (a.currency or "") != (b.currency or ""):
r.append(f"currency {a.currency} ≠ {b.currency}")
if not (a.per_share and b.per_share):
r.append("a valuation comparison needs two per-share values")
elif a.security_basis != b.security_basis and "NA" not in (a.security_basis, b.security_basis):
r.append(f"security basis {a.security_basis} ≠ {b.security_basis}")
return (not r), r
if comparison_type in ("level", "margin_relationship") and a.period_class != b.period_class:
r.append(f"period {a.period_class} ≠ {b.period_class}")
if comparison_type == "growth" and not ({a.period_class, b.period_class} == {"FORWARD", "TRAILING"}):
r.append("growth needs one trailing base and one forward figure")
if comparison_type in ("level", "margin_relationship", "growth"):
ba, bb = a.basis_class, b.basis_class
if MODEL not in (ba, bb) and ba != bb:
r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}")
if a.scope != b.scope:
r.append(f"scope {a.scope} ≠ {b.scope}")
if comparison_type in ("level", "growth") and ADJUSTED in (a.accounting_basis, b.accounting_basis):
# two adjusted figures are comparable only on one adjustment definition (or a declared reconciliation)
import definitions as _defs
ok_d, why_d = _defs.definitions_compatible(a, b)
if not ok_d:
r.append(why_d)
if comparison_type == "level" and a.period_class == "FORWARD" and b.period_class == "FORWARD":
import definitions as _defs
ok_p, why_p = _defs.periods_overlap(a, b)
if not ok_p:
r.append(why_p)
if comparison_type in ("level", "growth", "ratio"):
if (a.currency or "") != (b.currency or "") and a.unit not in ("ratio", "x") and b.unit not in ("ratio", "x"):
r.append(f"currency {a.currency} ≠ {b.currency}")
if a.per_share and b.per_share and a.security_basis != b.security_basis:
r.append(f"security basis {a.security_basis} ≠ {b.security_basis}")
if not _same_perimeter(a, b):
r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}")
if comparison_type == "ratio":
if SPOT not in (a.period_type, b.period_type) and a.period_class != b.period_class:
r.append("a multiple needs a spot price or matching periods")
return (not r), r
def require_compatible(a, b, comparison_type):
ok, reasons = compatible(a, b, comparison_type)
if not ok:
raise IncompatibleMetrics(f"{a.metric_id if a else None} vs {b.metric_id if b else None} "
f"({comparison_type}): " + "; ".join(reasons))
return True
# ── canonical selection: one explicit choice per use case ─────────────────────────────
SELECTION_POLICY = {
"EPS_GROWTH_BASE": ("historical.eps_adjusted_ttm",
"the EPS forecast is anchored to sell-side estimates, which are on the adjusted basis"),
"FORWARD_PE_DENOMINATOR": ("forecast.eps", "the reconciled 12-month EPS forecast"),
"TRAILING_PE_DENOMINATOR": ("historical.eps_adjusted_ttm",
"same basis as the forward EPS, so trailing and forward multiples compare like for like"),
"GAAP_PE_DENOMINATOR": ("historical.eps_gaap_ttm", "reported net income per diluted share"),
"STORY_START_REVENUE": ("historical.revenue_ttm", "the reconciled historical revenue every model uses"),
"STORY_START_MARGIN": ("historical.operating_margin_ttm",
"the reconciled historical operating margin every model uses"),
"FORECAST_GROWTH_BASE_REVENUE": ("historical.revenue_ttm", "the reconciled historical revenue"),
"FORECAST_ACCOUNTING_CHECK": ("forecast.operating_margin vs forecast.net_margin",
"both from the one reconciled forecast state, same period and basis"),
"INTRINSIC_VALUE": ("valuation.intrinsic_value", "the value produced by the engine selected for this issuer"),
"MARKET_PRICING": ("pricing.market_composite", "multiples and analyst targets, blended"),
"HEADLINE_RETURN": ("blend.headline_return", "the blended model-implied 12-month price return"),
"PM_RETURN": ("scenarios.pm_return", "the panel's probability-weighted scenario return"),
"DIVIDEND_YIELD": ("market.dividend_yield", "trailing dividends paid ÷ market capitalisation"),
"UNCERTAINTY_RANGE": ("uncertainty.scenario_range", "bear and bull scenario returns"),
}
# ── stages ────────────────────────────────────────────────────────────────────────────
STAGES = ("INITIAL", "DATA_VALIDATED", "FORECAST_VALIDATED", "ANALYSIS_FINALIZED", "REPORT_VALIDATED", "RENDERED")
NAMESPACES = ("identity", "issuer", "market", "historical", "consensus", "segments", "guidance", "forecast", "valuation",
"pricing", "quant", "macro", "technical", "sentiment", "insiders", "ownership", "leadership", "corporate_actions",
"context", "relations", "evidence", "scenarios", "blend", "recommendation", "uncertainty", "claims", "provenance")
# what each stage validates, and therefore seals
SEALS = {
"DATA_VALIDATED": ("identity", "issuer", "market", "historical", "consensus", "segments", "guidance"),
"FORECAST_VALIDATED": ("forecast", "valuation", "pricing", "quant", "macro", "technical", "sentiment",
"insiders", "ownership", "leadership", "corporate_actions", "context", "relations", "evidence"),
"ANALYSIS_FINALIZED": NAMESPACES,
}
def _freeze(o):
"""Read-only deep copy: dicts become mapping proxies, lists become tuples."""
if isinstance(o, (dict, MappingProxyType)):
return MappingProxyType({k: _freeze(v) for k, v in o.items()})
if isinstance(o, (list, tuple)):
return tuple(_freeze(v) for v in o)
if isinstance(o, set):
return frozenset(o)
return o
def _jsonable(o):
if isinstance(o, Metric):
return o.to_dict()
if isinstance(o, (dict, MappingProxyType)):
return {k: _jsonable(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [_jsonable(v) for v in o]
if isinstance(o, float) and o != o:
return None
return o
class Namespace:
__slots__ = ("_name", "_data", "_state")
def __init__(self, name, state):
object.__setattr__(self, "_name", name)
object.__setattr__(self, "_data", {})
object.__setattr__(self, "_state", state)
def __getattr__(self, k):
try:
return self._data[k]
except KeyError:
raise AttributeError(f"{self._name}.{k} is not set") from None
def __setattr__(self, k, v):
self._state._check_writable(self._name, k)
self._data[k] = v
def get(self, k, default=None):
return self._data.get(k, default)
def set(self, k, v):
setattr(self, k, v)
return v
def keys(self):
return self._data.keys()
def items(self):
return self._data.items()
def __contains__(self, k):
return k in self._data
class AnalysisState:
# declared for readers and type checkers; set through object.__setattr__ in __init__
_stage: str
_sealed: set
_frozen: bool
_history: list
version: str | None
identity: Namespace; issuer: Namespace; market: Namespace; historical: Namespace
consensus: Namespace; forecast: Namespace; valuation: Namespace; pricing: Namespace
quant: Namespace; macro: Namespace; technical: Namespace; sentiment: Namespace
insiders: Namespace; leadership: Namespace; corporate_actions: Namespace; context: Namespace
scenarios: Namespace; blend: Namespace; recommendation: Namespace; uncertainty: Namespace
claims: Namespace; provenance: Namespace
def __init__(self, ticker, run_id=None):
object.__setattr__(self, "_stage", "INITIAL")
object.__setattr__(self, "_sealed", set())
object.__setattr__(self, "_frozen", False)
object.__setattr__(self, "_history", [])
object.__setattr__(self, "version", None)
for ns in NAMESPACES:
object.__setattr__(self, ns, Namespace(ns, self))
self.identity.ticker = ticker
self.identity.run_id = run_id
# ── writes ────────────────────────────────────────────────────────────────────────
def __setattr__(self, k, v):
raise AttributeError("write through a namespace, e.g. state.forecast.eps = …")
def _check_writable(self, ns, key):
if self._frozen:
raise SealedStateError(f"state is frozen at {self._stage}; cannot set {ns}.{key}")
if ns in self._sealed:
raise SealedStateError(f"{ns} was sealed when its validation stage passed; cannot set {ns}.{key}")
# ── reads ─────────────────────────────────────────────────────────────────────────
def get(self, path, default=None):
"""Resolve 'forecast.eps' or 'valuation.story.assumptions.margin_target'."""
parts = path.split(".")
ns = getattr(self, parts[0], None)
if not isinstance(ns, Namespace):
return default
cur = ns.get(parts[1]) if len(parts) > 1 else ns
for p in parts[2:]:
if cur is None:
return default
if isinstance(cur, Metric):
cur = getattr(cur, p, None)
elif isinstance(cur, (dict, MappingProxyType)):
cur = cur.get(p)
elif isinstance(cur, (list, tuple)) and p.isdigit():
i = int(p)
cur = cur[i] if i < len(cur) else None
else:
cur = getattr(cur, p, None)
return default if cur is None else cur
def value(self, path, default=None):
v = self.get(path)
if isinstance(v, Metric):
return default if v.value is None else v.value
return default if v is None else v
def select(self, use_case):
"""The canonical metric for a use case (SELECTION_POLICY). Consumers never choose."""
path, _reason = SELECTION_POLICY[use_case]
return self.get(path)
# ── stages ────────────────────────────────────────────────────────────────────────
@property
def stage(self):
return self._stage
def require_stage(self, stage):
if STAGES.index(self._stage) < STAGES.index(stage):
raise StageOrderError(f"requires {stage}; state is at {self._stage}")
def advance(self, stage, validation):
"""Enter ``stage`` only in order and only when its validation passed."""
cur = STAGES.index(self._stage)
nxt = STAGES.index(stage)
if nxt != cur + 1:
raise StageOrderError(f"cannot move from {self._stage} to {stage}")
if not getattr(validation, "passed", False):
raise StageOrderError(f"{stage} validation did not pass: {getattr(validation, 'blocking', None)}")
for ns in SEALS.get(stage, ()):
if ns not in self._sealed:
n = getattr(self, ns)
object.__setattr__(n, "_data", {k: _freeze(v) for k, v in n._data.items()})
self._sealed.add(ns)
self._history.append({"stage": stage, "checks": len(getattr(validation, "checks", []) or [])})
object.__setattr__(self, "_stage", stage)
if stage == "ANALYSIS_FINALIZED":
object.__setattr__(self, "_frozen", True)
object.__setattr__(self, "version", self.content_hash())
return self
def mark(self, stage):
"""Post-finalization stages (REPORT_VALIDATED, RENDERED) record progress without writes."""
if STAGES.index(stage) != STAGES.index(self._stage) + 1:
raise StageOrderError(f"cannot move from {self._stage} to {stage}")
object.__setattr__(self, "_stage", stage)
@property
def frozen(self):
return self._frozen
# ── serialisation ─────────────────────────────────────────────────────────────────
def to_dict(self):
return {ns: _jsonable(dict(getattr(self, ns)._data)) for ns in NAMESPACES}
def content_hash(self):
blob = json.dumps(self.to_dict(), sort_keys=True, default=str)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def snapshot(self):
"""A deep, JSON-safe copy (for the QA record)."""
return copy.deepcopy(self.to_dict())