"""Multi-theme registry, symbol->theme exposure mapping, and combined scoring. Aggregates the three Thai alternative-factor themes (tourism, auto_credit, refining_energy) plus the Siamchart fundamental provider into a per-symbol combined score, per the user's confirmed weighting: combined = 0.6 * theme_score + 0.4 * siamchart_score where `theme_score` is the mean of the enabled theme scores that cover a symbol (so a symbol in several themes averages its theme scores), and `siamchart_score` is a normalized fundamental score. Frequency handling: theme scores carry an explicit `frequency` (daily/monthly/ quarterly/annual). Consumers must not mix different-frequency factors as if they were the same timestamp — see `frequency` on each scored theme. """ from __future__ import annotations import statistics from typing import Optional # --------------------------------------------------------------------------- # Theme registry + symbol->theme exposure mapping # --------------------------------------------------------------------------- # Symbols known to belong to each theme. This is a *curated* partial mapping of # the SET50 universe; symbols not listed here get no direct theme exposure for # that theme (theme_score contribution = those themes' factor value applied via # a generic market exposure fallback, see scoring). # # Expanded to cover the FULL SET50 universe (2026, 49 names): every symbol is # assigned to at least one industry/theme so the board + per-symbol detail can # always report a theme. Deterministic, curated by industry sector. THEME_SYMBOLS: dict[str, set[str]] = { "tourism": { "AOT", "CENTEL", "MINT", "AWC", "CPN", "CRC", "BEM", "BTS", "ERW", "DHOUSE", "MAJOR", "SNNP", }, "auto_credit": { "KKP", "TISCO", "TCAP", "THANI", "MTC", "SAWAD", "NSI", "AEONTS", "TK", "THG", "GLAND", "ASI", "TGPRO", }, "refining_energy": { "PTT", "PTTGC", "TOP", "IRPC", "SPRC", "BCP", "ESSO", "BANPU", "GPSC", }, "banks": {"BBL", "KBANK", "KTB", "SCB", "TTB"}, "retail": {"CPALL", "COM7", "GLOBAL", "HMPRO", "OSP", "OR", "CPN", "CRC"}, "telecom_it": {"ADVANC", "TRUE", "DELTA", "COM7"}, "property": {"LH", "AWC", "CPN", "CRC", "GLOBAL", "HMPRO"}, "healthcare": {"BDMS", "BH"}, "petrochem_materials": {"IVL", "SCC", "SCGP", "PTTGC", "BANPU"}, "consumer_staples": {"CPF", "TU", "OSP", "CBG"}, "utilities": {"BGRIM", "EGCO", "RATCH", "GPSC", "GULF", "BANPU", "EA"}, "nonbank_finance": {"JMT", "JMART", "KTC", "TIDLOR", "MTC", "SAWAD", "KTC", "AEONTS"}, "exploration": {"PTTEP", "PTT"}, } # Theme frequency (data cadence of the underlying factor). Used to keep # multi-frequency factors from being mixed as same-timestamp. THEME_FREQUENCY: dict[str, str] = { "tourism": "monthly", "auto_credit": "monthly", "refining_energy": "quarterly", "banks": "quarterly", "retail": "monthly", "telecom_it": "quarterly", "property": "quarterly", "healthcare": "quarterly", "petrochem_materials": "quarterly", "consumer_staples": "quarterly", "utilities": "quarterly", "nonbank_finance": "quarterly", "exploration": "quarterly", } # Thai labels for every theme (used in the board theme column + per-symbol view) THEME_LABELS_TH: dict[str, str] = { "tourism": "ท่องเที่ยว", "auto_credit": "รถยนต์/สินเชื่อ", "refining_energy": "พลังงาน/โรงกลั่น", "banks": "ธนาคาร", "retail": "ค้าปลีก", "telecom_it": "สื่อสาร/ไอที", "property": "อสังหาริมทรัพย์", "healthcare": "โรงพยาบาล", "petrochem_materials": "ปิโตรเคมี/วัสดุ", "consumer_staples": "อาหาร/อุปโภค", "utilities": "สาธารณูปโภค", "nonbank_finance": "การเงินนอกธนาคาร", "exploration": "สำรวจ/ผลิตพลังงาน", } # Declarative THEMES definition — which FACTORS drive each theme, with per-theme # weight (flexible: how much that factor plausibly impacts stock valuation in # this theme). A theme's surprise = weighted blend of its factors. ADDING a # factor to a theme = edit this dict; no scoring-function change. THEMES: dict[str, dict] = { "tourism": { "label_th": "ท่องเที่ยว", "factors": [ {"key": "tourism_arrivals_ytd", "weight": 1.0}, {"key": "macro_consumption", "weight": 0.4}, {"key": "external_current_account", "weight": 0.3}, ], }, "auto_credit": { "label_th": "รถยนต์/สินเชื่อ", "factors": [ {"key": "auto_sales_yoy", "weight": 1.0}, {"key": "auto_production", "weight": 0.4}, {"key": "auto_exports", "weight": 0.3}, {"key": "auto_npl", "weight": 0.6}, ], }, "refining_energy": { "label_th": "พลังงาน/โรงกลั่น", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, {"key": "energy_irpc_net_margin", "weight": 0.6}, {"key": "macro_mfg", "weight": 0.3}, ], }, "banks": { "label_th": "ธนาคาร", "factors": [ {"key": "macro_investment", "weight": 1.0}, {"key": "macro_inflation", "weight": 0.4}, {"key": "macro_core_inflation", "weight": 0.3}, {"key": "bank_npl", "weight": 0.6}, {"key": "te_interest_rate", "weight": 0.4}, {"key": "te_loan_growth", "weight": 0.4}, ], }, "retail": { "label_th": "ค้าปลีก", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "macro_inflation", "weight": 0.3}, {"key": "macro_core_inflation", "weight": 0.2}, {"key": "macro_unemployment", "weight": 0.3}, {"key": "external_imports", "weight": 0.2}, {"key": "external_current_account", "weight": 0.2}, {"key": "te_retail_sales_yoy", "weight": 0.7}, {"key": "te_consumer_confidence", "weight": 0.4}, ], }, "consumer_staples": { "label_th": "อาหาร/อุปโภค", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "macro_inflation", "weight": 0.2}, {"key": "macro_core_inflation", "weight": 0.15}, {"key": "macro_unemployment", "weight": 0.3}, {"key": "external_imports", "weight": 0.15}, {"key": "te_retail_sales_yoy", "weight": 0.6}, {"key": "te_consumer_confidence", "weight": 0.3}, ], }, "telecom_it": { "label_th": "สื่อสาร/ไอที", "factors": [ {"key": "macro_consumption", "weight": 0.8}, {"key": "macro_investment", "weight": 0.4}, {"key": "external_exports", "weight": 0.2}, {"key": "te_business_confidence", "weight": 0.3}, ], }, "property": { "label_th": "อสังหาริมทรัพย์", "factors": [ {"key": "macro_investment", "weight": 1.0}, {"key": "macro_consumption", "weight": 0.5}, {"key": "macro_inflation", "weight": 0.3}, {"key": "external_imports", "weight": 0.2}, {"key": "te_property_prices", "weight": 0.9}, {"key": "te_business_confidence", "weight": 0.3}, ], }, "healthcare": { "label_th": "โรงพยาบาล", "factors": [ {"key": "macro_consumption", "weight": 0.5}, {"key": "macro_unemployment", "weight": 0.4}, {"key": "te_business_confidence", "weight": 0.2}, ], }, "petrochem_materials": { "label_th": "ปิโตรเคมี/วัสดุ", "factors": [ {"key": "macro_mfg", "weight": 1.0}, {"key": "macro_inflation", "weight": 0.3}, {"key": "external_exports", "weight": 0.4}, {"key": "external_current_account", "weight": 0.3}, ], }, "utilities": { "label_th": "สาธารณูปโภค", "factors": [ {"key": "macro_mfg", "weight": 1.0}, {"key": "energy_net_margin", "weight": 0.3}, {"key": "energy_irpc_net_margin", "weight": 0.2}, ], }, "nonbank_finance": { "label_th": "การเงินนอกธนาคาร", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "auto_npl", "weight": 0.3}, {"key": "macro_unemployment", "weight": 0.3}, {"key": "te_consumer_credit", "weight": 0.5}, {"key": "te_household_debt_gdp", "weight": 0.4}, {"key": "te_consumer_confidence", "weight": 0.3}, ], }, "exploration": { "label_th": "สำรวจ/ผลิตพลังงาน", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, {"key": "energy_irpc_net_margin", "weight": 0.4}, {"key": "macro_inflation", "weight": 0.2}, {"key": "external_exports", "weight": 0.3}, {"key": "external_current_account", "weight": 0.2}, ], }, } # --------------------------------------------------------------------------- # Scoring helpers # --------------------------------------------------------------------------- def _zscore(values: list) -> dict: """Normalize a list of floats to z-scores, clamped to [-3, 3].""" n = len(values) if n == 0: return {} mean = sum(values) / n var = sum((v - mean) ** 2 for v in values) / n std = var ** 0.5 or 1.0 out = {} for i, v in enumerate(values): z = (v - mean) / std out[i] = max(-3.0, min(3.0, z)) return out def compute_theme_surprises(fetched: dict, tourism_surprise: Optional[float] = None) -> dict: """Registry-driven per-theme surprise — THE single source of truth. `fetched` maps fetch-module name -> collector dict (e.g. {"macro_thai": {...}, "auto_credit": {...}, "energy_thai": {...}}). For each theme in `THEMES`, the surprise is the **weighted average** of its declared FACTORS (each normalized by its own center/span/sign and extracted via `factor_value`), normalised by the total absolute weight of the factors that actually contributed. Normalising by |weight| keeps every theme's surprise on the same [-1, 1] scale regardless of how many (or how heavy) its factors are, so a surprise of +0.2 means the same thing for retail and for banks (cross-theme comparable — important for P4 weight learning). `tourism_surprise` (optional) overrides the tourism theme so the richer bot-tourism observation z-score can win when available; otherwise tourism falls back to its registry factors. """ from . import factors as factors_mod out: dict[str, Optional[float]] = {} for tid, tdef in THEMES.items(): weighted = 0.0 w_sum = 0.0 for ref in tdef.get("factors", []): fkey = ref.get("key") fact = factors_mod.FACTORS.get(fkey) if not fact: continue val = factors_mod.factor_value(fact, fetched.get(fact.get("fetch"))) if val is None: continue norm = factors_mod.normalize(val, sign=fact.get("sign", 1), center=fact.get("center", 0.0), span=fact.get("span", 10.0)) if norm is None: continue w = float(ref.get("weight", 1.0)) weighted += w * norm w_sum += abs(w) if w_sum == 0: out[tid] = None else: s = max(-1.0, min(1.0, weighted / w_sum)) out[tid] = round(s, 3) # tourism override: prefer the observation-derived surprise when provided. if tourism_surprise is not None and "tourism" in out: out["tourism"] = round(max(-1.0, min(1.0, float(tourism_surprise))), 3) return out def factor_source_breakdown(fetched: dict, theme_id: str) -> list: """Per-factor contribution detail for one theme (what the user asked for). For each FACTOR a theme references, show exactly how it contributed to the theme surprise: - source: the fetch-module name (e.g. 'macro_thai', 'te_thailand') - name_th: the factor's Thai label - raw: the raw collected value - normalized: the sign/center/span-normalized score in [-1, 1] - weight: the per-theme weight (positive magnitude; direction is in sign) - contribution: weight * normalized - missing: True when the source had no value so the factor was dropped This is the audit trail that lets the owner see "which source scored what, and how the weight was applied" and tune weights/thesis more easily. """ from . import factors as factors_mod tdef = THEMES.get(theme_id, {}) rows = [] for ref in tdef.get("factors", []): fkey = ref.get("key") fact = factors_mod.FACTORS.get(fkey) if not fact: continue fetch_mod = fact.get("fetch") val = factors_mod.factor_value(fact, fetched.get(fetch_mod)) w = float(ref.get("weight", 1.0)) if val is None: rows.append({ "factor": fkey, "source": fetch_mod, "name_th": fact.get("name_th", fkey), "frequency": fact.get("frequency", "monthly"), "sign": fact.get("sign", 1), "raw": None, "normalized": None, "weight": w, "contribution": None, "missing": True, }) continue norm = factors_mod.normalize(val, sign=fact.get("sign", 1), center=fact.get("center", 0.0), span=fact.get("span", 10.0)) rows.append({ "factor": fkey, "source": fetch_mod, "name_th": fact.get("name_th", fkey), "frequency": fact.get("frequency", "monthly"), "sign": fact.get("sign", 1), "raw": round(val, 4) if val is not None else None, "normalized": norm, "weight": w, "contribution": round(w * (norm or 0.0), 4) if norm is not None else None, "missing": False, }) return rows # Siamchart score is a declarative weighted blend of fundamentals + momentum — # each term is a first-class factor WITHIN the formula (not an out-of-formula # adjustment). Weights are config in one place so tuning/auditing is trivial. # - eps_growth (R1, PEAD): earnings drift dominates; literature Bernard-Thomas # 1990, Livnat-Mendenhall 2006. # - dividend_yield: value floor. # - momentum: price-trend factor in the formula (R2); EM momentum is noisier # so it stays small relative to EPS. _SIAMCHART_WEIGHTS: dict[str, float] = { "eps_growth": 1.5, "dividend_yield": 2.0, "momentum": 0.5, } _SIAMCHART_GROWTH_W = _SIAMCHART_WEIGHTS["eps_growth"] _SIAMCHART_YIELD_W = _SIAMCHART_WEIGHTS["dividend_yield"] _SIAMCHART_MOMENTUM_W = _SIAMCHART_WEIGHTS["momentum"] def siamchart_raw_score(g: float, d: float, m: float) -> float: """Raw (pre-z) Siamchart score = single source of the scoring formula. Momentum is an explicit term INSIDE the formula (per the owner's rule — it is a factor that enters the formula, not an out-of-formula tweak). All three weights come from ``_SIAMCHART_WEIGHTS`` so the board, the per-symbol detail, and the population stats always use byte-identical arithmetic. """ return ( _SIAMCHART_WEIGHTS["eps_growth"] * g + _SIAMCHART_WEIGHTS["dividend_yield"] * d + _SIAMCHART_WEIGHTS["momentum"] * m ) def _load_momentum(lookback_days: int = 252) -> dict[str, float]: """12-1 momentum per symbol from the latest price snapshot (deterministic). R2 (Jegadeesh-Titman 1993; EM evidence: weaker but positive). Returns {symbol: (close_today / close_{-12m}) - 1}. Lookback uses trading days so it aligns to ~12 calendar months. """ try: from . import simulation series = simulation.load_price_snapshot() except Exception: return {} out: dict[str, float] = {} for sym, s in series.items(): bars = s.get("bars", []) if len(bars) < lookback_days + 1: continue try: today = float(bars[-1]["adjusted_close"]) base = float(bars[-1 - lookback_days]["adjusted_close"]) except (KeyError, TypeError, ValueError, IndexError): continue if today <= 0 or base <= 0: continue out[sym] = round((today / base) - 1.0, 4) return out def price_trend_score(series: dict, lookbacks=(63, 126, 252), weights=(0.4, 0.35, 0.25)) -> dict[str, float]: """Mid-term price trend per symbol — the owner's definition of "ทำกำไร". The owner wants buckets 1/2 to mean "a stock whose price is likely to rise in the next 3-6 months", which is a *price-trend* signal, not EPS growth. This blends multi-horizon momentum over ~3 / 6 / 12 months (trading days), then z-scores across the universe so the score is comparable. Heavier weight on the shorter horizons (3/6m) matches the 3-6 month tenure the owner named. Returns {symbol: z(trend)}. Symbols without enough price history are omitted (callers treat them as ineligible/momentum-neutral). """ import statistics mom = {sym: [] for sym in series} for sym, s in series.items(): bars = s.get("bars", []) if not bars: continue todays = float(bars[-1]["adjusted_close"]) if todays <= 0: continue for lb in lookbacks: if len(bars) > lb: base = float(bars[-1 - lb]["adjusted_close"]) if base > 0: mom[sym].append((todays / base) - 1.0) else: mom[sym].append(0.0) else: mom[sym].append(None) raw: dict[str, float] = {} for sym, vals in mom.items(): contrib = [w * (v or 0.0) for v, w in zip(vals, weights) if v is not None] if contrib: raw[sym] = sum(contrib) if not raw: return {} vals = list(raw.values()) mean = statistics.mean(vals) sd = statistics.pstdev(vals) or 1.0 return {sym: round((v - mean) / sd, 4) for sym, v in raw.items()} def build_siamchart_score(factors: dict, momentum: Optional[dict[str, float]] = None) -> dict[str, float]: """Derive a normalized fundamental score from the Siamchart factor view. Raw score = eps_growth*w + dividend_yield*w + momentum*w (see ``siamchart_raw_score``) — momentum is a first-class factor INSIDE the formula, then z-scored across the universe. Optional momentum (price trend) contributes per its configured weight in ``_SIAMCHART_WEIGHTS``. """ out: dict[str, float] = {} for f in factors.get("factors", []): sym = f.get("symbol") if not sym: continue g = f.get("eps_growth_yoy") d = f.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 m = (momentum or {}).get(sym, 0.0) out[sym] = siamchart_raw_score(g, d, m) syms = list(out.keys()) z = _zscore([out[s] for s in syms]) return {s: z.get(i, 0.0) for i, s in enumerate(syms)} def combine_score(theme_scores: list[dict[str, float]], siamchart_score: dict[str, float], weight_theme: float = 0.6, weight_siamchart: float = 0.4) -> dict[str, dict]: """Combine per-symbol theme (averaged) and siamchart scores into final. Returns {symbol: {'theme_score', 'siamchart_score', 'combined', 'themes':[...]}}. """ all_syms: dict[str, list[float]] = {} theme_membership: dict[str, list[str]] = {} for ts in theme_scores: for sym, val in ts.items(): all_syms.setdefault(sym, []).append(val) theme_membership.setdefault(sym, []).append(sym) merged: dict[str, dict] = {} # every symbol that has a siamchart score OR a theme score universe = set(all_syms) | set(siamchart_score) for sym in universe: tl = all_syms.get(sym, []) tavg = (sum(tl) / len(tl)) if tl else 0.0 sc = siamchart_score.get(sym, 0.0) combined = weight_theme * tavg + weight_siamchart * sc merged[sym] = { "theme_score": tavg, "siamchart_score": sc, "combined": combined, "themes": theme_membership.get(sym, []), } return merged def quality_within_theme(symbol: str, theme_id: str, factor_view: dict) -> float: """Relative firm quality of a symbol within its theme cohort (stock picking). Deterministic, bounded to [0.5, 1.5] around 1.0: - compute the theme cohort = all THEME_SYMBOLS[theme_id] that have a factor row - quality = 1.0 + 0.5 * z(ROE, cohort) (above cohort = >1, below = <1) - damped by 0.3 * z(EPS growth, cohort) Stronger names in a hot theme rank higher -> the theme actually "picks" stocks instead of giving every member the same flat surprise. """ cohort = [s for s in THEME_SYMBOLS.get(theme_id, set()) if s != symbol] fmap = {f.get("symbol"): f for f in factor_view.get("factors", [])} fac = fmap.get(symbol) if not fac: return 1.0 roe = fac.get("roe") epsg = fac.get("eps_growth_yoy") def _cohort_z(val, getter): vals = [] for c in cohort: cf = fmap.get(c) v = getter(cf) if v is not None: vals.append(float(v)) if not vals or val is None: return 0.0 mean = statistics.fmean(vals) stdev = statistics.pstdev(vals) if stdev < 1e-9: return 0.0 return (float(val) - mean) / stdev z_roe = _cohort_z(roe, lambda f: f.get("roe") if f else None) z_epsg = _cohort_z(epsg, lambda f: f.get("eps_growth_yoy") if f else None) quality = 1.0 + 0.5 * max(min(z_roe, 2.0), -2.0) + 0.3 * max(min(z_epsg, 2.0), -2.0) return round(max(min(quality, 1.5), 0.5), 3) def symbol_breakdown( symbol: str, *, factor_view: dict, theme_surprises: dict[str, float], latest_price: Optional[float] = None, price_date: str = "", weight_theme: float = 0.6, weight_siamchart: float = 0.4, momentum: Optional[dict[str, float]] = None, ) -> dict: """Transparent per-symbol scoring breakdown. Shows exactly how `combined` was derived: theme_score = mean of the theme surprise scores covering this symbol siamchart_score = z-scored (EPS growth + dividend_yield*2) combined = weight_theme*theme_score + weight_siamchart*siamchart_score Returns a dict suitable for the /api/v1/symbols/ view. Deterministic and reuses the same formula as `combine_score` so the board and the detail always agree. """ # the themes this symbol belongs to (from the curated exposure map) member_themes = [tid for tid, syms in THEME_SYMBOLS.items() if symbol in syms] theme_lines = [] theme_values = [] for tid in member_themes: s = theme_surprises.get(tid) q = quality_within_theme(symbol, tid, factor_view) if s is not None: ts = round(float(s) * q, 3) theme_values.append(ts) theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": round(float(s), 3), "quality": q, "theme_score": ts, }) else: theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": None, "quality": q, "theme_score": None, }) theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0 # find the factor row for this symbol fac = next((f for f in factor_view.get("factors", []) if f.get("symbol") == symbol), {}) g = fac.get("eps_growth_yoy") d = fac.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 m = (momentum or {}).get(symbol, 0.0) raw_siamchart = siamchart_raw_score(g, d, m) # z-score against the full universe (same as build_siamchart_score); capture # the population stats so the view can show HOW -2.8 became -0.588. siamchart_map = build_siamchart_score(factor_view, momentum=momentum) siamchart_score = siamchart_map.get(symbol, 0.0) # recompute the population of raw scores to expose mean / stdev raw_values = [] for f in factor_view.get("factors", []): if not f.get("symbol"): continue gg = f.get("eps_growth_yoy") dd = f.get("dividend_yield") or 0.0 gg = float(gg) if gg is not None else 0.0 mm = (momentum or {}).get(f.get("symbol"), 0.0) raw_values.append(siamchart_raw_score(gg, dd, mm)) pop_mean = statistics.mean(raw_values) if raw_values else 0.0 pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0 combined = round(weight_theme * theme_score + weight_siamchart * siamchart_score, 3) return { "symbol": symbol, "company_name": fac.get("company_name", ""), "themes": member_themes, "theme_contributions": theme_lines, "theme_score": round(theme_score, 3), "siamchart_score": round(siamchart_score, 3), "siamchart_components": { "eps_growth_yoy": fac.get("eps_growth_yoy"), "dividend_yield": fac.get("dividend_yield"), "raw_growth_plus_yield_2x": round(raw_siamchart, 3), }, "siamchart_z_note": { "formula": "z = (raw_i - mean) / stdev", "raw_i": round(raw_siamchart, 3), "population_mean": round(pop_mean, 3), "population_stdev": round(pop_stdev, 3), "universe_size": len(raw_values), }, "combined_calc": [ {"label": "คะแนนธีม", "value": round(theme_score, 3), "weight": weight_theme, "note": "ค่าเฉลี่ยของ theme surprise ที่หุ้นนี้อยู่ใน", "term": f"{theme_score:.3f} × {weight_theme}"}, {"label": "คะแนนพื้นฐาน", "value": round(siamchart_score, 3), "weight": weight_siamchart, "note": f"z-score ของ ((EPS growth {g:+.1f}%) + ปันผล {d:.1f}%×2) เมื่อเทียบทั้ง universe", "term": f"{siamchart_score:.3f} × {weight_siamchart}"}, ], "combined_score": combined, "combined_formula": f"({theme_score:.3f} × {weight_theme}) + ({siamchart_score:.3f} × {weight_siamchart}) = {combined}", "weights": {"theme": weight_theme, "siamchart": weight_siamchart}, "fundamentals": { "pe": fac.get("pe"), "eps": fac.get("eps"), "pbv": fac.get("pbv"), "roe": fac.get("roe"), "dps": fac.get("dps"), "is_dividend": fac.get("is_dividend"), }, "price": {"latest": latest_price, "date": price_date}, }