Ctrl K
ComunidadGuías…

scripts/_prediction-country-index.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import countryCodes from './data/country-codes.json' with { type: 'json' };
import predictionCountryLanguage from './shared/prediction-country-language.json' with { type: 'json' };
import { isExpired, shouldInclude } from './_prediction-scoring.mjs';

export const COUNTRY_MARKET_LIMIT = 5;

export function countCountryMarkets(countryMarkets) {
  return Object.values(countryMarkets ?? {})
    .reduce((count, markets) => count + (Array.isArray(markets) ? markets.length : 0), 0);
}

const PREDICTION_COUNTRY_LANGUAGE = predictionCountryLanguage.countries ?? {};

function normalizeSearchText(value) {
  const words = String(value)
    .normalize('NFKD')
    .replace(/\p{M}/gu, '')
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, ' ')
    .trim();
  return words ? ` ${words} ` : '';
}

const COUNTRY_TERM_SHADOWS = Object.freeze(Object.fromEntries(
  Object.entries(predictionCountryLanguage.termShadows ?? {}).map(([countryCode, shadow]) => [
    countryCode,
    { ...shadow, term: normalizeSearchText(shadow.term) },
  ]),
));

function compileCountryMatchers(countries) {
  return Object.entries(countries).map(([countryCode, country]) => {
    const name = String(country?.name ?? '').trim();
    const language = PREDICTION_COUNTRY_LANGUAGE[countryCode] ?? {};
    const excludedBaseTerms = new Set(language.excludedBaseTerms ?? []);
    const keywords = [...new Set([
      ...(country?.keywords ?? []),
      ...(language.terms ?? []),
    ]
      .map((keyword) => String(keyword).trim())
      .filter((keyword) => keyword
        && keyword.toLowerCase() !== name.toLowerCase()
        && !excludedBaseTerms.has(keyword.toLowerCase())))];
    return {
      countryCode,
      name: normalizeSearchText(name),
      keywords: keywords.map(normalizeSearchText),
      requiredContext: (language.requiredContext ?? []).map(normalizeSearchText),
      excludedPhrases: (language.excludedPhrases ?? []).map(normalizeSearchText),
    };
  });
}

function termOccurrences(normalizedTitle, term, matchStrength, countryCode) {
  const matches = [];
  let start = normalizedTitle.indexOf(term);
  while (start >= 0) {
    matches.push({ countryCode, start, end: start + term.length, term, matchStrength });
    start = normalizedTitle.indexOf(term, start + 1);
  }
  return matches;
}

function occurrenceFallsWithin(normalizedTitle, candidate, enclosingTerm) {
  let start = normalizedTitle.indexOf(enclosingTerm);
  while (start >= 0) {
    if (start <= candidate.start && start + enclosingTerm.length >= candidate.end) return true;
    start = normalizedTitle.indexOf(enclosingTerm, start + 1);
  }
  return false;
}

function countryMatches(normalizedTitle, matchers) {
  const rawMatches = [];
  const matcherByCountryCode = new Map(matchers.map((matcher) => [matcher.countryCode, matcher]));
  for (const matcher of matchers) {
    const hasCountryContext = matcher.requiredContext.length === 0
      || matcher.requiredContext.some((term) => normalizedTitle.includes(term));
    if (matcher.name && hasCountryContext) {
      rawMatches.push(...termOccurrences(normalizedTitle, matcher.name, 2, matcher.countryCode));
    }
    for (const keyword of matcher.keywords) {
      rawMatches.push(...termOccurrences(normalizedTitle, keyword, 1, matcher.countryCode));
    }
  }

  const strongest = new Map();
  for (const candidate of rawMatches) {
    const shadow = COUNTRY_TERM_SHADOWS[candidate.countryCode];
    const shadowedByCountryContext = shadow
      && candidate.term === shadow.term
      && rawMatches.some((other) => other.countryCode === shadow.specificCountryCode);
    const matcher = matcherByCountryCode.get(candidate.countryCode);
    const shadowedByPhrase = (matcher?.excludedPhrases ?? [])
      .some((phrase) => occurrenceFallsWithin(normalizedTitle, candidate, phrase));
    const embeddedInSpecificCountry = rawMatches.some((other) => (
      other.countryCode !== candidate.countryCode
      && other.term.length > candidate.term.length
      && other.start <= candidate.start
      && other.end >= candidate.end
    ));
    if (shadowedByCountryContext || shadowedByPhrase || embeddedInSpecificCountry) continue;
    const incumbent = strongest.get(candidate.countryCode);
    if (!incumbent
      || candidate.matchStrength > incumbent.matchStrength
      || (candidate.matchStrength === incumbent.matchStrength && candidate.term.length > incumbent.term.length)) {
      strongest.set(candidate.countryCode, candidate);
    }
  }
  return strongest;
}

function marketEventIdentity(market) {
  const eventKey = String(market?.eventKey ?? '').trim();
  if (eventKey) return eventKey;
  const url = String(market?.url ?? '').trim();
  if (url) return url;
  return String(market?.title ?? '').trim().toLowerCase();
}

function closeTime(market) {
  const value = Date.parse(String(market?.endDate ?? ''));
  return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
}

function volume(market) {
  const value = Number(market?.volume);
  return Number.isFinite(value) ? value : 0;
}

function compareRankedMarkets(left, right) {
  return left.closeAt - right.closeAt
    || right.matchStrength - left.matchStrength
    || right.volume - left.volume
    || String(left.market.title).localeCompare(String(right.market.title));
}

function dedupeEvents(ranked) {
  const best = new Map();
  for (const candidate of ranked) {
    const identity = marketEventIdentity(candidate.market);
    const incumbent = best.get(identity);
    if (!incumbent || compareRankedMarkets(candidate, incumbent) < 0) {
      best.set(identity, candidate);
    }
  }
  return [...best.values()].sort(compareRankedMarkets);
}

function selectWithProviderCoverage(ranked, limit) {
  const selected = ranked.slice(0, limit);
  const availableSources = new Set(ranked.map(({ market }) => market.source).filter(Boolean));
  const selectedSources = new Set(selected.map(({ market }) => market.source).filter(Boolean));

  for (const source of availableSources) {
    if (selectedSources.has(source)) continue;
    const candidate = ranked.find(({ market }) => market.source === source);
    if (!candidate) continue;
    if (selected.length < limit) selected.push(candidate);
    else selected[selected.length - 1] = candidate;
    selectedSources.add(source);
  }

  return [...new Map(selected.map((candidate) => [marketEventIdentity(candidate.market), candidate])).values()]
    .sort(compareRankedMarkets)
    .slice(0, limit);
}

function publicMarket(market) {
  return {
    title: market.title,
    yesPrice: market.yesPrice,
    volume: market.volume,
    ...(typeof market.liquidity === 'number' ? { liquidity: market.liquidity } : {}),
    url: market.url,
    ...(market.endDate ? { endDate: market.endDate } : {}),
    source: market.source,
    ...(market.source === 'kalshi' && market.displayUrl !== undefined ? { displayUrl: market.displayUrl } : {}),
  };
}

export function buildCountryMarketIndex(markets, {
  limit = COUNTRY_MARKET_LIMIT,
  now = Date.now(),
  countries = countryCodes,
} = {}) {
  const matchers = compileCountryMatchers(countries);
  const candidates = Array.isArray(markets)
    ? markets
      .filter((market) => market && !isExpired(market.endDate, now))
      .map((market) => {
        const normalizedTitle = normalizeSearchText(market.title);
        return { market, countryMatches: countryMatches(normalizedTitle, matchers) };
      })
    : [];
  const strictCandidates = candidates.filter(({ market }) => shouldInclude(market));
  const relaxedCandidates = candidates.filter(({ market }) => shouldInclude(market, true));
  const index = {};

  for (const matcher of matchers) {
    const rank = (eligible) => dedupeEvents(eligible
      .map(({ market, countryMatches: matches }) => {
        const match = matches.get(matcher.countryCode);
        return {
          market,
          matchStrength: match?.matchStrength ?? 0,
          closeAt: closeTime(market),
          volume: volume(market),
        };
      })
      .filter(({ matchStrength }) => matchStrength > 0));

    let ranked = rank(strictCandidates);
    if (ranked.length < limit) ranked = rank(relaxedCandidates);
    if (ranked.length === 0) continue;

    index[matcher.countryCode] = selectWithProviderCoverage(ranked, limit)
      .map(({ market }) => publicMarket(market));
  }

  return index;
}

export function selectKalshiSeriesTickers(series, targetCountryCodes, {
  perCountryLimit = 2,
  totalLimit = Number.POSITIVE_INFINITY,
  excludedTickers = [],
  countries = countryCodes,
} = {}) {
  const targets = new Set((targetCountryCodes ?? []).map((code) => String(code).toUpperCase()));
  const limit = Math.max(0, Math.floor(Number(perCountryLimit) || 0));
  const parsedTotalLimit = Number(totalLimit);
  const maxSelected = Number.isFinite(parsedTotalLimit)
    ? Math.max(0, Math.floor(parsedTotalLimit))
    : Number.POSITIVE_INFINITY;
  const excluded = new Set((excludedTickers ?? [])
    .map((ticker) => String(ticker).trim())
    .filter(Boolean));
  if (!Array.isArray(series) || targets.size === 0 || limit === 0 || maxSelected === 0) return [];

  const matchers = compileCountryMatchers(countries)
    .filter(({ countryCode }) => targets.has(countryCode));
  const rankedByCountry = new Map(matchers.map(({ countryCode }) => [countryCode, []]));

  for (const entry of series) {
    const ticker = String(entry?.ticker ?? '').trim();
    if (!ticker || excluded.has(ticker) || String(entry?.category ?? '').toLowerCase() === 'sports') continue;
    const title = [entry?.title, ...(Array.isArray(entry?.tags) ? entry.tags : [])]
      .map((part) => String(part ?? '').trim())
      .filter(Boolean)
      .join(' ');
    const parsedVolume = Number(entry?.volume_fp);
    const candidate = {
      title,
      yesPrice: 50,
      volume: Number.isFinite(parsedVolume) ? parsedVolume : 0,
    };
    if (!shouldInclude(candidate)) continue;

    for (const [countryCode, match] of countryMatches(normalizeSearchText(title), matchers)) {
      rankedByCountry.get(countryCode)?.push({ ticker, title, ...match, volume: candidate.volume });
    }
  }

  const selected = new Set();
  for (const { countryCode } of matchers) {
    rankedByCountry.get(countryCode)
      ?.sort((left, right) => right.matchStrength - left.matchStrength
        || right.volume - left.volume
        || left.title.localeCompare(right.title)
        || left.ticker.localeCompare(right.ticker))
      .slice(0, limit)
      .forEach(({ ticker }) => {
        if (selected.size < maxSelected) selected.add(ticker);
      });
    if (selected.size >= maxSelected) break;
  }
  return [...selected];
}

/**
 * Build the country index only when every source segment succeeded.
 * A partial fetch must yield `{}` so `skipWhenEmpty` retains the last-good
 * extra key instead of publishing an incomplete map as authoritative empty.
 */
export function projectCountryMarketIndex(markets, { complete, ...options } = {}) {
  if (complete !== true) return {};
  return buildCountryMarketIndex(markets, options);
}