// Cortex — help registry + <Help> popover component.
//
// Click the small ? icon next to any data point to see how that number was
// computed. Entries match identifiers used elsewhere in the UI; if you add
// a new metric, add it here and reference its id with <Help id="...">.
//
// Sources are pointed at the Python module and DB column that actually
// produce the value, so a curious PM can find the code in one hop.

const HELP = {

  // ──────────────────────────────────────────────────────────── factor scores
  quality_score: {
    title: 'Quality score (0–100)',
    description:
      'Percentile rank within the eligible Nordic universe of a weighted blend of ' +
      'profitability efficiency, margin stability and capital discipline. Higher = better.',
    formula:
      '0.25·ROIC + 0.20·EBIT margin + 0.20·cash conversion + 0.15·balance sheet + ' +
      '0.10·margin stability + 0.10·dilution discipline   (each percentile-ranked)',
    source: 'cortex.scores.quality_score · scoring.QUALITY_WEIGHTS',
  },
  value_score: {
    title: 'Value score (0–100)',
    description:
      'Percentile-rank blend of cheapness signals. Lower multiples / higher yields → higher score. ' +
      'EV/EBITDA is null because Börsdata doesn’t expose D&A separately.',
    formula:
      '0.30·−EV/EBIT + 0.25·FCF yield + 0.20·−EV/EBITDA + 0.15·earnings yield + ' +
      '0.10·valuation vs own 5y median   (each percentile-ranked)',
    source: 'cortex.scores.value_score · scoring.VALUE_WEIGHTS',
  },
  growth_score: {
    title: 'Growth score (0–100)',
    description:
      'Real growth, not just topline. Sales-per-share weighted explicitly so dilution drags the score.',
    formula:
      '0.30·revenue CAGR 3Y + 0.25·sales/share 3Y growth + 0.20·EBIT YoY + ' +
      '0.15·margin expansion + 0.10·FCF growth   (each percentile-ranked)',
    source: 'cortex.scores.growth_score · scoring.GROWTH_WEIGHTS',
  },
  momentum_score: {
    title: 'Momentum score (0–100)',
    description:
      '12-month-minus-1 carries the most weight (classic academic specification — skip the most ' +
      'recent month to avoid short-term reversal contamination).',
    formula:
      '0.35·12M-ex-1M + 0.25·6M return + 0.20·rel. strength vs sector + 0.20·trend stability ' +
      '(low vol)   (each percentile-ranked)',
    source: 'cortex.scores.momentum_score · scoring.MOMENTUM_WEIGHTS',
  },
  inflection_score: {
    title: 'Inflection score (0–100)',
    description:
      'Detects fundamentals that are bending positively year-over-year. ' +
      'Useful for surfacing turnarounds before they show up in the trailing-twelve-month value print.',
    formula:
      '0.30·EBIT margin Δ + 0.25·revenue growth acceleration + 0.20·FCF improvement + ' +
      '0.15·ROIC improvement + 0.10·net debt reduction   (each percentile-ranked)',
    source: 'cortex.scores.inflection_score · scoring.INFLECTION_WEIGHTS',
  },
  risk_score: {
    title: 'Risk score (0–100, higher = worse)',
    description:
      'The only score where high is bad. Aggregates leverage, dilution, liquidity, FCF reliability, ' +
      'volatility and recent margin direction.',
    formula:
      '0.25·leverage + 0.20·dilution + 0.20·liquidity (illiquidity rank) + 0.15·FCF risk + ' +
      '0.10·volatility + 0.10·margin deterioration   (each percentile-ranked)',
    source: 'cortex.scores.risk_score · scoring.RISK_WEIGHTS',
  },
  liquidity_score: {
    title: 'Liquidity score (0–100)',
    description:
      'Direct percentile rank of trailing 90-day average daily turnover. Used as the ' +
      'compounder bucket’s ≥40 gate so we don’t pick names we can’t trade.',
    formula: 'pct_rank( avg_daily_turnover_90d )',
    source: 'cortex.scores.liquidity_score · features.avg_daily_turnover_90d',
  },
  ai_score: {
    title: 'AI text score (0–100)',
    description:
      'Percentile rank of Claude’s composite read on the company’s last 90 days of ' +
      'Cision press releases. Mean of 6 directional subscores (operating momentum, balance ' +
      'sheet, dilution, customer concentration, governance, management tone — risk fields ' +
      'pre-inverted so higher always = better). NULL for ~60 % of eligible names today ' +
      '(Cision is Sweden-only and not every Swedish issuer publishes there).',
    formula: 'pct_rank( mean(6 subscores) over 90d window )',
    source: 'cortex.scores.ai_score · ai_features.compute_ai_features()',
  },

  // ──────────────────────────────────────────────────────── bucket composites
  primary_signal_bucket: {
    title: 'Bucket assignment',
    description:
      'Companies that pass a bucket’s eligibility gate get a composite score; the bucket ' +
      'with the highest composite wins. "Danger" short-circuits — risk ≥ 75 sends the name ' +
      'there regardless of other scores. Names that pass NO bucket’s gate are unflagged ' +
      'and don’t appear in the queue (you can still pull them up from the tear-sheet selector).',
    formula:
      'compounder · cheap_decent · inflection · pullback · danger   (mutually exclusive)',
    source: 'cortex.scores.primary_signal_bucket · scoring._assign_primary_bucket',
  },
  compounder_score: {
    title: 'Compounder composite',
    description:
      'Quality-led blend. Filters: quality ≥ 80, growth ≥ 60, risk ≤ 50, liquidity ≥ 40. ' +
      'Composite score AI factor at 10 %; other positives scaled 0.90× to make room.',
    formula:
      '0.45·Q + 0.225·G + 0.135·M + 0.09·V + 0.10·AI − 0.25·Risk',
    source: 'cortex.scores.compounder_score · scoring.COMPOUNDER_W + COMPOUNDER_CUTS',
  },
  cheap_decent_score: {
    title: 'Cheap-but-decent composite',
    description:
      'Value-led with a quality floor. Filters: value ≥ 80, quality ≥ 50, risk ≤ 60, ' +
      '≥3 of last 5 years with positive FCF.',
    formula:
      '0.405·V + 0.225·Q + 0.135·M + 0.135·Balance + 0.10·AI − 0.30·Risk',
    source: 'cortex.scores.cheap_decent_score · scoring.CHEAP_DECENT_W + CHEAP_DECENT_CUTS',
  },
  inflection_candidate_score: {
    title: 'Inflection-candidate composite',
    description:
      'Turnaround-leaning. Filters: inflection ≥ 75, momentum ≥ 50, risk ≤ 70.',
    formula:
      '0.405·Inflection + 0.225·M + 0.135·V + 0.135·Q + 0.10·AI − 0.25·Risk',
    source: 'cortex.scores.inflection_candidate_score · scoring.INFLECTION_W + INFLECTION_CUTS',
  },
  quality_pullback_score: {
    title: 'Quality pullback composite',
    description:
      'Quality names that have already taken a hit. Filters: quality ≥ 75, risk ≤ 60, ' +
      'drawdown from 52w high ≤ −25 %.',
    formula:
      '0.405·Q + 0.225·V + 0.135·Balance + 0.10·AI − 0.25·Risk',
    source: 'cortex.scores.quality_pullback_score · scoring.PULLBACK_W + PULLBACK_CUTS',
  },
  danger_score: {
    title: 'Danger score (= risk score)',
    description:
      'The avoid list — companies with risk ≥ 75. AI is intentionally NOT in this composite ' +
      'so an upbeat press release can’t pull a balance-sheet-weak name out.',
    formula: 'danger_score = risk_score   (filter: risk ≥ 75)',
    source: 'cortex.scores.danger_score · scoring.DANGER_RISK_MIN',
  },

  // ─────────────────────────────────────────── valuation & profitability features
  ev_ebit: {
    title: 'EV / EBIT',
    description:
      'Enterprise value (market cap + net debt) over trailing EBIT. Lower = cheaper. ' +
      'Computed only when EBIT > 0; otherwise null.',
    formula: 'EV / EBIT = (market_cap + net_debt) / ebit   (where ebit > 0)',
    source: 'cortex.features_quarterly.ev_ebit · features._derive_valuation_ratios',
  },
  ev_sales: {
    title: 'EV / Sales',
    description: 'Enterprise value over trailing revenue. Sector-agnostic cheapness proxy when EBIT < 0.',
    formula: 'EV / Sales = (market_cap + net_debt) / revenue   (where revenue > 0)',
    source: 'cortex.features_quarterly.ev_sales',
  },
  fcf_yield: {
    title: 'FCF yield (%)',
    description: 'Free cash flow as a fraction of market cap. Positive number = market currently pays you to own this.',
    formula: 'FCF yield = free_cash_flow / market_cap',
    source: 'cortex.features_quarterly.fcf_yield',
  },
  earnings_yield: {
    title: 'Earnings yield',
    description: 'Inverse of P/E (well, P/Net-income). Used inside the Value composite.',
    formula: 'earnings_yield = net_income / market_cap',
    source: 'cortex.features_quarterly.earnings_yield',
  },
  gross_margin: {
    title: 'Gross margin',
    description: 'Gross profit (revenue − COGS) divided by revenue, from the latest annual.',
    formula: 'gross_margin = gross_profit / revenue',
    source: 'cortex.features_quarterly.gross_margin · features._fundamental_features',
  },
  ebit_margin: {
    title: 'EBIT margin',
    description: 'Operating income over revenue. Börsdata exposes "operating_margin" directly; we fall back to a recomputation if missing.',
    formula: 'ebit_margin = COALESCE(operating_margin, ebit / revenue)',
    source: 'cortex.features_quarterly.ebit_margin',
  },
  fcf_margin: {
    title: 'FCF margin',
    description: 'Free cash flow as a fraction of revenue — captures cash-generating efficiency.',
    formula: 'fcf_margin = free_cash_flow / revenue',
    source: 'cortex.features_quarterly.fcf_margin',
  },
  roic: {
    title: 'ROIC (pre-tax)',
    description:
      'EBIT divided by invested capital, where invested capital = total equity + net debt. ' +
      'Pre-tax approximation — Börsdata doesn’t expose effective tax rate per company. ' +
      'For net-cash companies (equity > 0, net_debt < 0) the denominator can be small, ' +
      'which inflates ROIC; treat values above ~50 % with a grain of salt.',
    formula: 'roic = ebit / (equity + net_debt)   (positive denominator only)',
    source: 'cortex.features_quarterly.roic',
  },
  roe: {
    title: 'Return on equity',
    description: 'Net income over book equity. Only computed when equity > 0.',
    formula: 'roe = net_income / equity   (where equity > 0)',
    source: 'cortex.features_quarterly.roe',
  },
  cash_conversion: {
    title: 'Cash conversion',
    description:
      'Free cash flow over net income — how much of reported earnings turn into actual cash. ' +
      'Persistent < 1 is a yellow flag.',
    formula: 'cash_conversion = free_cash_flow / net_income',
    source: 'cortex.features_quarterly.cash_conversion',
  },
  net_debt_ebitda: {
    title: 'Net debt / EBIT (≈ND/EBITDA proxy)',
    description:
      'Leverage indicator. Börsdata’s annuals don’t separate D&A, so we use EBIT in the ' +
      'denominator instead of EBITDA. Negative values = net-cash companies.',
    formula: 'leverage = net_debt / ebit   (we don’t have EBITDA)',
    source: 'cortex.features_quarterly.net_debt_ebitda',
  },
  equity_ratio: {
    title: 'Equity ratio',
    description: 'Book equity over total assets. Higher = more conservatively financed.',
    formula: 'equity_ratio = equity / total_assets',
    source: 'cortex.features_quarterly.equity_ratio',
  },

  // ──────────────────────────────────────────────── growth & inflection features
  revenue_growth_yoy: {
    title: 'Revenue growth YoY',
    description: 'Year-over-year change in revenue from the latest two annual reports.',
    formula: 'revenue_growth_yoy = revenue_t / revenue_{t-1} − 1',
    source: 'cortex.features_quarterly.revenue_growth_yoy',
  },
  revenue_cagr_3y: {
    title: 'Revenue CAGR (3-year)',
    description: 'Compound annual growth rate of revenue over the last three annuals — smoothes one-off years.',
    formula: 'revenue_cagr_3y = (revenue_t / revenue_{t−3}) ^ (1/3) − 1   (positive ratio)',
    source: 'cortex.features_quarterly.revenue_cagr_3y',
  },
  sales_per_share_growth_3y: {
    title: 'Sales-per-share growth (3Y)',
    description:
      'Revenue per share over 3 years — penalises growth that came from issuing more shares ' +
      'rather than running the business better.',
    formula: '(revenue/shares)_t / (revenue/shares)_{t−3} − 1',
    source: 'cortex.features_quarterly.sales_per_share_growth_3y',
  },
  ebit_growth_yoy: {
    title: 'EBIT growth YoY',
    description: 'Year-over-year change in operating income.',
    formula: 'ebit_growth_yoy = ebit_t / ebit_{t−1} − 1',
    source: 'cortex.features_quarterly.ebit_growth_yoy',
  },
  share_count_growth_3y: {
    title: 'Share-count growth (3Y)',
    description: 'Positive = dilution; negative = buybacks. A clean compounder typically scores ≤ 0 here.',
    formula: 'shares_t / shares_{t−3} − 1',
    source: 'cortex.features_quarterly.share_count_growth_3y',
  },
  fcf_positive_years_5y: {
    title: 'FCF-positive years (last 5)',
    description: 'Count of years with positive free cash flow in the last 5 annual reports. Used by the cheap-but-decent gate (≥ 3 required).',
    formula: 'count(free_cash_flow > 0) over last 5 annuals',
    source: 'cortex.features_quarterly.fcf_positive_years_5y',
  },
  margin_stability_5y: {
    title: 'Margin stability (5Y)',
    description: 'Standard deviation of EBIT margin over the last 5 annuals. LOWER is better — we invert it before percentile-ranking inside Quality.',
    formula: 'stdev( ebit_margin over last 5 annuals )',
    source: 'cortex.features_quarterly.margin_stability_5y',
  },
  ebit_margin_inflection: {
    title: 'EBIT-margin inflection (YoY)',
    description: 'Latest EBIT margin minus the year-before — captures the direction of operational leverage.',
    formula: 'ebit_margin_t − ebit_margin_{t−1}',
    source: 'cortex.features_quarterly.ebit_margin_inflection',
  },
  revenue_growth_acceleration: {
    title: 'Revenue-growth acceleration',
    description: 'Year-over-year change in YoY growth — second derivative of revenue. Catches inflections before margins follow.',
    formula: 'revenue_growth_yoy_t − revenue_growth_yoy_{t−1}',
    source: 'cortex.features_quarterly.revenue_growth_acceleration',
  },
  fcf_improvement_yoy: {
    title: 'FCF improvement (YoY)',
    description: 'YoY change in free-cash-flow margin.',
    formula: 'fcf_margin_t − fcf_margin_{t−1}',
    source: 'cortex.features_quarterly.fcf_improvement_yoy',
  },
  net_debt_reduction_yoy: {
    title: 'Net-debt reduction (YoY)',
    description: 'Sign-flipped change in net debt — positive value = company paid down debt.',
    formula: '−(net_debt_t − net_debt_{t−1})',
    source: 'cortex.features_quarterly.net_debt_reduction_yoy',
  },
  roic_improvement_yoy: {
    title: 'ROIC improvement (YoY)',
    description: 'Year-over-year change in ROIC.',
    formula: 'roic_t − roic_{t−1}',
    source: 'cortex.features_quarterly.roic_improvement_yoy',
  },

  // ──────────────────────────────────────────────────────────── price / liquidity
  price_momentum_1m: {
    title: 'Price momentum 1M',
    description: 'Simple return over the last 21 trading days.',
    formula: 'close_t / close_{t−21} − 1',
    source: 'cortex.features_quarterly.price_momentum_1m',
  },
  price_momentum_3m: {
    title: 'Price momentum 3M',
    description: 'Simple return over the last 63 trading days (~3 calendar months).',
    formula: 'close_t / close_{t−63} − 1',
    source: 'cortex.features_quarterly.price_momentum_3m',
  },
  price_momentum_6m: {
    title: 'Price momentum 6M',
    description: 'Simple return over the last 126 trading days.',
    formula: 'close_t / close_{t−126} − 1',
    source: 'cortex.features_quarterly.price_momentum_6m',
  },
  price_momentum_12m_ex_1m: {
    title: 'Price momentum 12M-ex-1M',
    description:
      'Twelve-month return skipping the most recent month. The academic standard for momentum ' +
      '(Jegadeesh-Titman 1993) — short-term reversal can flip the sign otherwise.',
    formula: 'close_{t−21} / close_{t−252} − 1',
    source: 'cortex.features_quarterly.price_momentum_12m_ex_1m',
  },
  volatility_12m: {
    title: '12-month volatility (annualised)',
    description: 'Standard deviation of daily log returns over the last 252 trading days, scaled to a yearly figure.',
    formula: 'stdev( log returns over 252d ) × √252',
    source: 'cortex.features_quarterly.volatility_12m',
  },
  drawdown_from_52w_high: {
    title: 'Drawdown from 52-week high',
    description: 'Always ≤ 0. Used by the quality-pullback bucket (cutoff: ≤ −25 %).',
    formula: 'close_t / max(close over last 252d) − 1',
    source: 'cortex.features_quarterly.drawdown_from_52w_high',
  },
  relative_strength_sector_6m: {
    title: 'Relative strength vs sector (6M)',
    description: 'Company’s 6-month return minus the median 6-month return across its sector. Positive = outperforming peers.',
    formula: 'mom_6m − median(mom_6m within sector)',
    source: 'cortex.features_quarterly.relative_strength_sector_6m',
  },
  avg_daily_turnover_30d: {
    title: '30-day avg daily turnover',
    description: 'Mean of (close × volume) over the last 30 trading days. Local currency.',
    formula: 'mean( close × volume ) over last 30 trading days',
    source: 'cortex.features_quarterly.avg_daily_turnover_30d',
  },
  avg_daily_turnover_90d: {
    title: '90-day avg daily turnover',
    description: 'Same as 30d but over 90 trading days — what feeds the liquidity bucket assignment.',
    formula: 'mean( close × volume ) over last 90 trading days',
    source: 'cortex.features_quarterly.avg_daily_turnover_90d',
  },
  zero_volume_days_90d: {
    title: 'Zero-volume days (90d)',
    description: 'Count of days in the last 90 trading days with zero reported volume — a sanity check on liquidity.',
    formula: 'count( volume == 0 ) over last 90 trading days',
    source: 'cortex.features_quarterly.zero_volume_days_90d',
  },
  thirty_d: {
    title: 'Δ 30D',
    description: 'Simple return between the latest close and the close ~21 trading days earlier.',
    formula: 'close_now / close_~21td_back − 1',
    source: 'computed inline by web_build._fetch_30d_returns from cortex.prices_daily_v',
  },

  // ──────────────────────────────────────────────────────────── identity / market
  market_cap_local: {
    title: 'Market cap (local currency)',
    description: 'Latest close × shares outstanding, kept in the reported currency for ratios.',
    formula: 'close × shares_outstanding',
    source: 'cortex.features_quarterly.market_cap_local_mns',
  },
  mcap_eur: {
    title: 'Market cap (≈ EUR)',
    description:
      'Local-currency market cap converted to EUR at a flat rate (SEK→EUR ≈ 1/11.5). ' +
      'For cross-Nordic comparability in the design. Not an FX-accurate quote.',
    formula: 'mcap_eur = market_cap_local × (1 / 11.5)',
    source: 'web_build.SEK_TO_EUR',
  },
  market_cap_bucket: {
    title: 'Market-cap bucket',
    description:
      'Coarse size class in SEK-equivalent. Cuts at 250M / 1B / 5B / 15B. ' +
      'Eligibility for the queue requires bucket ∈ { micro, small, small_mid }.',
    formula: 'nano <250M · micro 250M–1B · small 1B–5B · small_mid 5B–15B · too_large >15B',
    source: 'features.CAP_BUCKETS · features._assign_buckets',
  },
  liquidity_bucket: {
    title: 'Liquidity bucket',
    description:
      'Classification by 90-day ADV in SEK-equivalent. Cuts at 100k / 500k / 2M. ' +
      'The queue excludes "illiquid"; everything else is potentially investable.',
    formula: 'illiquid <100k · thin 100k–500k · ok 500k–2M · liquid >2M',
    source: 'features.LIQ_BUCKETS · features._assign_buckets',
  },
  bucket_rank: {
    title: 'Rank within bucket',
    description:
      'Position when names in the same bucket are sorted by that bucket’s composite score. ' +
      'Recomputed client-side from the persisted scores.',
    source: 'shared.jsx rank loop',
  },
  flag_avoid: {
    title: 'Flag: AVOID',
    description: 'Set whenever the primary signal bucket is "danger" (risk score ≥ 75).',
    source: 'web_build._rows_to_records',
  },
  flag_buy: {
    title: 'Flag: BUY',
    description: 'Placeholder for top-of-compounder candidates — design uses it as an example, not yet wired to a real promotion signal.',
    source: 'web_build (placeholder)',
  },
  flag_new: {
    title: 'Flag: NEW',
    description: 'Placeholder for "first-time appearance in the queue" — not yet derived from a queue-history table.',
    source: 'web_build (placeholder)',
  },

  // ──────────────────────────────────────────────────────────────────── AI
  ai_text_score: {
    title: 'AI text score (raw, 0–100)',
    description:
      'Mean of Claude’s six directional subscores over all Cision documents in the last 90 days. ' +
      'Risk fields are pre-inverted (higher = lower risk) so the mean is monotone.',
    formula: 'mean(operating_momentum, balance_sheet, dilution, customer_concentration, governance, management_tone)',
    source: 'cortex.ai_document_scores_v.ai_composite_raw · ai_features.compute_ai_features',
  },
  ai_n_documents_90d: {
    title: 'Documents in 90d window',
    description: 'Count of Cision press releases for this company in the 90 days before the as-of date that Claude has scored.',
    formula: 'count(documents with published_date in [asof − 90d, asof])',
    source: 'cortex.ai_document_scores_v',
  },
  ai_latest_doc_date: {
    title: 'Latest doc',
    description: 'Publication date of the most recent press release in the 90-day window.',
    source: 'cortex.ai_document_scores_v',
  },

  // ───────────────────────────────────────────────────────── similarity engine
  similarity_numeric: {
    title: 'Numeric similarity',
    description:
      'Euclidean distance over a 12-dim financial fingerprint: 4 quality, 2 growth, 3 capital-structure, ' +
      '2 size/liquidity (log-scaled) and 12m volatility. Each axis is rank-then-z-score-normalised so ' +
      'no single feature dominates. NaN-tolerant: pairs that overlap on fewer dimensions get penalised.',
    formula:
      'distance(a, b) = √( Σ over present dims (z(a_i) − z(b_i))² · 12 / n_present )',
    source: 'cortex.similar_companies · similarity.compute_for_date',
  },
  similarity_semantic: {
    title: 'Semantic similarity (OpenAI embeddings)',
    description:
      'Cosine similarity between text-embedding-3-large (3072-dim) vectors built from each company’s ' +
      'static metadata + latest annual report figures + Claude’s business-model descriptor + ' +
      'last 90 days of press releases. Captures "what the company does and says" — orthogonal to the ' +
      'financial fingerprint.',
    formula: 'cosine_similarity( embedding_a, embedding_b )   over OpenAI text-embedding-3-large',
    source: 'cortex.company_embeddings · embeddings.find_similar',
  },
  similarity_dims_used: {
    title: 'Dimensions used',
    description:
      'How many of the 12 numeric features both companies had values for. Pairs with < 8 are hidden ' +
      'because the NaN scaling can put noise at the top of the list.',
    source: 'cortex.similar_companies.n_features_used',
  },

  // ───────────────────────────────────────────────────────────────── price chart
  price_chart: {
    title: 'Price chart',
    description:
      'Local-currency close prices from Börsdata, forward-filled across holidays. Period return shown ' +
      'in the top right is from the first to last close in the selected horizon.',
    formula: 'period_return = visible_close_last / visible_close_first − 1',
    source: 'cortex.prices_daily_v · PriceChart in page-tearsheet.jsx',
  },

  // ──────────────────────────────────────────────────────────── backtest panel
  backtest_kpi_cagr: {
    title: 'CAGR (annualised return)',
    description: 'Total return compounded to an annual rate over the backtest period.',
    formula: 'CAGR = (1 + total_return) ^ (1 / years) − 1',
    source: 'cortex.backtest module · _compute_kpis',
  },
  backtest_kpi_sharpe: {
    title: 'Sharpe ratio',
    description: 'Excess return per unit of total volatility. Risk-free rate set to zero — this is a relative metric, not an absolute one.',
    formula: 'Sharpe = mean(daily return) / stdev(daily return) × √252',
    source: 'cortex.backtest._compute_kpis',
  },
  backtest_kpi_max_drawdown: {
    title: 'Max drawdown',
    description: 'Largest peak-to-trough loss over the backtest period. Always ≤ 0.',
    formula: 'min over time of ( equity_t / cummax(equity)_t − 1 )',
    source: 'cortex.backtest._compute_drawdown',
  },
  backtest_kpi_hit_rate: {
    title: 'Hit rate (monthly)',
    description: 'Fraction of months in which the portfolio outperformed the benchmark.',
    source: 'cortex.backtest._compute_kpis',
  },
  backtest_kpi_avg_holding: {
    title: 'Average holding period',
    description: 'Mean time a position stays in the book — a proxy for portfolio turnover.',
    source: 'cortex.backtest._compute_kpis',
  },
  backtest_kpi_turnover: {
    title: 'Turnover (annualised)',
    description: 'Sum of buys + sells in a year as a fraction of average book value.',
    source: 'cortex.backtest._compute_kpis',
  },
  backtest_equity_curve: {
    title: 'Equity curve',
    description: 'Indexed to 100 at backtest start. Dashed line is the benchmark (Carnegie Nordic Small Cap NR). 1.2 % management fee netted off the strategy.',
    source: 'cortex.backtest_portfolio_returns · web_build._fetch_backtest_payload',
  },
  backtest_attribution: {
    title: 'Attribution by bucket',
    description:
      'Per-bucket P&L contribution from a bucket-segmented backtest of the strategy. ' +
      'NOTE: bucket-attribution requires per-bucket sub-runs that aren’t yet wired ' +
      'in cortex.backtest — values here are illustrative placeholders.',
    source: 'placeholder (cortex.backtest sub-runs not implemented)',
  },

  // ──────────────────────────────────────────────────────── model rankings
  model_rankings_page: {
    title: 'Model rankings — what this page shows',
    description:
      'Each "model" is a separately-trained ranker that scores every name in the eligible Nordic ' +
      'universe on a single number. Multiple models can coexist (typical: a baseline factor blend, ' +
      'an XGBoost on the same features, an LLM-flavoured signal). The page shows the currently-' +
      'selected model’s top picks for the latest prediction date. The summary tiles up top let ' +
      'you compare models against each other on a single 12-month-forward backtest.',
    source: 'cortex.model_predictions · cortex.model_backtest_summary',
  },
  model_prediction_rank: {
    title: 'Rank (within model)',
    description:
      'Ordinal position when the model’s scores are sorted descending. Rank 1 = the model’s ' +
      'top pick. A typical research workflow takes the top-20 to top-30 names and stages them ' +
      'into the queue.',
    formula: 'rank = position when score is sorted DESC',
    source: 'cortex.model_predictions.rank',
  },
  model_prediction_score: {
    title: 'Model score (raw)',
    description:
      'The raw output of the selected model for this ticker on the selected prediction date. ' +
      'Higher = more bullish. Scale is model-specific — for the factor-blend baseline it’s ' +
      'roughly bounded; for tree-based models it can be any real number. Use rank for ' +
      'cross-model comparison, not raw score.',
    source: 'cortex.model_predictions.score',
  },
  model_avg_excess_return_12m: {
    title: 'Average 12-month excess return',
    description:
      'Out-of-sample test. For each historical prediction_date, take the top-N names the model ' +
      'picked, compute their equal-weighted forward 12-month total return, and subtract the ' +
      'benchmark (Carnegie Nordic Small Cap NR) over the same window. The tile shows the mean ' +
      'across all periods the model has been backtested on.',
    formula: 'mean over periods( EW return of top-N − benchmark return )   (12m forward)',
    source: 'cortex.model_backtest_summary.avg_excess_return_12m',
  },
  model_avg_hit_rate: {
    title: 'Average hit rate',
    description:
      'Across all backtest periods, the share where the model’s top-N portfolio outperformed ' +
      'the benchmark over 12 months forward. Coin-flip = 50 %.',
    formula: 'mean over periods( 1 if portfolio_return > benchmark_return else 0 )',
    source: 'cortex.model_backtest_summary.avg_hit_rate',
  },
  model_periods: {
    title: 'Backtest periods',
    description:
      'Number of historical prediction dates the model has been scored on. More periods = more ' +
      'confidence in the average. < 8 should be treated as preliminary.',
    source: 'cortex.model_backtest_summary.periods',
  },
  model_avg_portfolio_return_12m: {
    title: 'Average 12-month portfolio return',
    description:
      'Equal-weighted forward 12-month total return of the model’s top-N names, averaged across ' +
      'all backtest periods. Compare to the benchmark to see whether the model adds value.',
    formula: 'mean over periods( EW return of top-N )   (12m forward, total return)',
    source: 'cortex.model_backtest_summary.avg_portfolio_return_12m',
  },
  model_avg_benchmark_return_12m: {
    title: 'Average 12-month benchmark return',
    description:
      'Forward 12-month total return of Carnegie Nordic Small Cap NR, averaged across the same ' +
      'periods used in the portfolio number. Apples-to-apples reference for the excess column.',
    source: 'cortex.model_backtest_summary.avg_benchmark_return_12m',
  },
  model_selected: {
    title: 'Currently selected model',
    description:
      'The model whose top-N is displayed in the table below. By default we pick the highest ' +
      'avg-excess-return-12m model that has actually exported predictions for the latest ' +
      'prediction date.',
    source: 'cortex.web_build._fetch_model_predictions',
  },
  model_prediction_date: {
    title: 'Prediction date',
    description:
      'Date the model produced this ranking. Distinct from the feature snapshot date — production ' +
      'models usually run on a daily or weekly cadence independent of when scores were last ' +
      'computed.',
    source: 'cortex.model_predictions.prediction_date',
  },
  model_tile: {
    title: 'Model comparison tile',
    description:
      'Each tile is one model in the registry. Headline number = avg 12-month excess return ' +
      'over the benchmark across all backtest periods (out of sample). Subtitle = average hit ' +
      'rate and period count. The leftmost tile is highlighted — that’s the currently selected ' +
      'model whose top picks are shown below.',
    source: 'cortex.model_backtest_summary',
  },

  // ──────────────────────────────────────────────────────────────── watchlist
  watch_model_agreement: {
    title: 'Model agreement',
    description:
      'How aligned the model is with the PM holding/watching this name. ' +
      '"Concur" = high quality + low risk; "disagree" = the opposite. Helps focus discussion on names where the model and the PM see different things.',
    formula: 'score = 0.4·Q + 0.3·G + 0.15·Inflection + 0.15·(100 − Risk)',
    source: 'page-watchlist.jsx ModelAgreement',
  },
  watch_weight: {
    title: 'Position weight',
    description: 'Portfolio weight if held. Not yet wired to a real positions table — placeholder until a positions feed is integrated.',
    source: 'placeholder',
  },
};

// ─── <Help> component ──────────────────────────────────────────────────────
// Renders a small "?" icon that opens an inline popover with the registry
// entry. Click outside or on the X to dismiss. Stops click propagation so the
// help icon doesn’t also trigger row-click handlers.

function Help({ id, side = 'right' }) {
  const [open, setOpen] = React.useState(false);
  const containerRef = React.useRef(null);

  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (!containerRef.current?.contains(e.target)) setOpen(false);
    };
    const onEsc = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onEsc);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onEsc);
    };
  }, [open]);

  const entry = HELP[id];
  const hasEntry = !!entry;

  const stop = (e) => e.stopPropagation();

  return (
    <span
      ref={containerRef}
      onClick={stop}
      style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', marginLeft: 8 }}
    >
      <button
        onClick={(e) => { stop(e); setOpen(o => !o); }}
        title={hasEntry ? entry.title : `help for ${id}`}
        style={{
          appearance: 'none', cursor: 'pointer', padding: 0,
          width: 18, height: 18, borderRadius: 9,
          background: open ? CX.accent : CX.accentDim,
          color: open ? CX.bg : CX.accent,
          border: `1px solid ${open ? CX.accent : 'rgba(123,229,164,0.55)'}`,
          fontFamily: CX.mono, fontSize: 11, fontWeight: 700, lineHeight: 1,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          transition: 'color .12s, background .12s, border-color .12s, transform .12s',
        }}
        onMouseEnter={(e) => {
          if (open) return;
          e.currentTarget.style.background = CX.accent;
          e.currentTarget.style.color = CX.bg;
          e.currentTarget.style.transform = 'scale(1.1)';
        }}
        onMouseLeave={(e) => {
          if (open) return;
          e.currentTarget.style.background = CX.accentDim;
          e.currentTarget.style.color = CX.accent;
          e.currentTarget.style.transform = 'scale(1)';
        }}
      >?</button>
      {open && (
        <div
          onClick={stop}
          style={{
            position: 'absolute',
            top: 'calc(100% + 6px)',
            [side === 'left' ? 'right' : 'left']: 0,
            width: 360, maxWidth: '90vw',
            background: CX.panelHi, border: `1px solid ${CX.lineHi}`,
            padding: '14px 16px',
            zIndex: 100, fontFamily: CX.display,
            boxShadow: '0 8px 28px rgba(0,0,0,0.6)',
          }}
        >
          {hasEntry ? (
            <>
              <Mono style={{ fontSize: 10, color: CX.accent, letterSpacing: '0.16em', textTransform: 'uppercase' }}>
                {entry.title}
              </Mono>
              <div style={{ marginTop: 10, fontSize: 13, color: CX.text, lineHeight: 1.55, textWrap: 'pretty' }}>
                {entry.description}
              </div>
              {entry.formula && (
                <div style={{ marginTop: 12, padding: '8px 10px', background: 'rgba(255,255,255,0.03)',
                  borderLeft: `2px solid ${CX.accent}`, fontFamily: CX.mono, fontSize: 11,
                  color: CX.text, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
                  {entry.formula}
                </div>
              )}
              {entry.source && (
                <Mono style={{ marginTop: 10, display: 'block', fontSize: 10, color: CX.faint, letterSpacing: '0.06em' }}>
                  source · {entry.source}
                </Mono>
              )}
            </>
          ) : (
            <Mono style={{ fontSize: 11, color: CX.faint, letterSpacing: '0.06em' }}>
              no help entry registered for &quot;{id}&quot;
            </Mono>
          )}
        </div>
      )}
    </span>
  );
}

window.Help = Help;
window.HELP = HELP;
