@claudinho/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @claudinho/core ⚽
2
2
 
3
- Shared domain model, data-provider adapters, and helpers for **Claudinho** —
4
- the 2026 men's football tournament in your dev environment. This is the engine
5
- behind [`@claudinho/cli`](https://www.npmjs.com/package/@claudinho/cli) and
3
+ Shared domain model, data-provider adapters, a read-only market-signal sidecar,
4
+ and helpers for **Claudinho** — the 2026 men's football tournament in your dev
5
+ environment. This is the engine behind
6
+ [`@claudinho/cli`](https://www.npmjs.com/package/@claudinho/cli) and
6
7
  [`@claudinho/mcp`](https://www.npmjs.com/package/@claudinho/mcp).
7
8
 
8
9
  > ⚠️ **Not affiliated with, endorsed by, or connected to FIFA or Anthropic.**
@@ -22,6 +23,7 @@ npm i @claudinho/core
22
23
  - **Live overlay** — `makeAdapter`, `getMatchesForDate`, `getLiveMatches`, `mergeLive` (static base + live state, with graceful degradation)
23
24
  - **Standings** — `computeStandings` (points / GD / GF tiebreak)
24
25
  - **Helpers** — emoji flags (`nationToFlag`), TZ-aware time (`formatKickoff`, `countdown`, `localDate`), location strings (`matchLocation`), localized commentary flair (`matchFlavor` / `FlavorLevel`), validators (`isValidDate`, `isValidTimeZone`)
26
+ - **Prediction-market signals (sidecar)** — read-only market odds kept *separate* from `Match`: the `MarketSignal` / `MarketProvider` model, the `PolymarketProvider` (public Gamma data only — no auth/trading/links; event slugs auto-derived per fixture, validation fails closed), a `FakeMarketProvider`, `makeMarketProvider`, `getMarketSignal` / `getMarketSignals`, the `isReliableMarketSignal` gate, and approved-copy formatters (`marketFavoriteText`, `marketProbabilityText`, `marketBlock`). Informational only — never betting advice.
25
27
 
26
28
  ## Example
27
29
 
package/dist/index.d.ts CHANGED
@@ -394,7 +394,15 @@ interface LiveResult {
394
394
  matches: Match[];
395
395
  /** True when the provider call failed and we fell back to static data. */
396
396
  degraded: boolean;
397
+ /**
398
+ * The live-data provider that served this result (e.g. "espn"), for
399
+ * attribution. Absent when `degraded` — the bundled static schedule, served
400
+ * by no live provider, must not be attributed to one.
401
+ */
402
+ source?: string;
397
403
  }
404
+ /** Human label for a live-data provider name (attribution). Text only. */
405
+ declare function liveSourceLabel(source: string): string;
398
406
  /**
399
407
  * Matches for a date, preferring live provider data, falling back to the static
400
408
  * schedule on any provider/network error (graceful degradation).
@@ -403,4 +411,302 @@ declare function getMatchesForDate(adapter: ProviderAdapter, dateISO: string): P
403
411
  /** Currently-live matches; empty + degraded on error. */
404
412
  declare function getLiveMatches(adapter: ProviderAdapter): Promise<LiveResult>;
405
413
 
406
- export { DEFAULT_COMPETITION, DEFAULT_FLAVOR, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, type FlavorLevel, type FormatOpts, type LedgerRow, type LiveResult, type MapContext, type Match, type MatchEvent, type Outcome, type ProviderAdapter, type ProviderCapabilities, type PunditPick, type Stage, type StandingRow, type Status, type Team, allFixtures, asFlavorLevel, byKickoff, competitionBase, computeStandings, countdown, fixturesByDate, fixturesByGroup, fixturesByTeam, flagEmoji, formatKickoff, getLiveMatches, getMatchesForDate, groups, isFinished, isFlavorLevel, isLive, isValidDate, isValidTimeZone, localDate, makeAdapter, mapEspnEvent, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, outcomeFromScore, resolveCompetition, resolveTz, scoreline };
414
+ /**
415
+ * Prediction-market "signal" model — a *sidecar* to Match, deliberately never
416
+ * embedded in it. Market data has different freshness, reliability, failure,
417
+ * and legal semantics than tournament facts, so it lives in its own folder and
418
+ * is keyed back to a match by id. This keeps prediction-market context off the
419
+ * hot paths (statusline, hook) by construction rather than by remembering a flag.
420
+ *
421
+ * Read-only by design: providers fetch public market data only — no wallet,
422
+ * no auth, no order placement. Nothing here models trading.
423
+ */
424
+
425
+ /** Which match result a priced line refers to (home win / draw / away win). */
426
+ type MarketOutcomeKind = 'home' | 'draw' | 'away' | 'other';
427
+ /** One priced outcome of a match market. */
428
+ interface MarketOutcome {
429
+ kind: MarketOutcomeKind;
430
+ /** Team code for 'home'/'away' (e.g. "MEX"); absent for draw/other. */
431
+ teamCode?: string;
432
+ /** Label as the source displays it (e.g. "Mexico", "Draw"). */
433
+ label: string;
434
+ /** Market-implied probability, normalized to [0,1] (sums to ~1 across a market). */
435
+ probability: number;
436
+ }
437
+ /** How strongly the market leans toward its top outcome. */
438
+ type FavoriteStrength = 'close' | 'slight' | 'clear';
439
+ /** The top outcome plus its strength bucket. */
440
+ interface MarketFavorite {
441
+ kind: 'home' | 'draw' | 'away';
442
+ teamCode?: string;
443
+ probability: number;
444
+ strength: FavoriteStrength;
445
+ }
446
+ /** A normalized prediction-market reading for a single match. */
447
+ interface MarketSignal {
448
+ /** Claudinho match id this signal maps to. */
449
+ matchId: string;
450
+ /** Provider name (e.g. "polymarket"). */
451
+ source: string;
452
+ /**
453
+ * Opaque source market id, for debugging and mapping only. Deliberately never
454
+ * surfaced as a clickable link in v1 (see the no-outbound-links guardrail).
455
+ */
456
+ sourceMarketId?: string;
457
+ /** When the source last priced the market (ISO 8601 UTC). */
458
+ asOf: string;
459
+ /** When Claudinho fetched it (ISO 8601 UTC). */
460
+ fetchedAt: string;
461
+ /** Priced outcomes, normalized so positive probabilities sum to ~1. */
462
+ outcomes: MarketOutcome[];
463
+ /** Top outcome, when one can be determined from a clean mapping. */
464
+ favorite?: MarketFavorite;
465
+ /** Source liquidity, when available (provider units; used only for gating). */
466
+ liquidity?: number;
467
+ /** Source 24h volume, when available (provider units). */
468
+ volume24h?: number;
469
+ /** True when the snapshot is older than the freshness window. */
470
+ stale: boolean;
471
+ /** True when the market does not map cleanly to the displayed home/draw/away result. */
472
+ ambiguous: boolean;
473
+ }
474
+ /** Tunables for reliability gating and provider fetch behavior. */
475
+ interface MarketSignalOptions {
476
+ /** "Now" for staleness math; defaults to the current time. */
477
+ now?: Date;
478
+ /** Minimum source liquidity required to treat a signal as reliable. */
479
+ minLiquidity?: number;
480
+ /** Max age (ms) before a signal is considered stale. */
481
+ maxAgeMs?: number;
482
+ /**
483
+ * Bypass reliability gates — used by the dedicated `markets` surface, which
484
+ * may show a thin/stale market *with a caveat*. Default surfaces never set this.
485
+ */
486
+ includeUnreliable?: boolean;
487
+ /**
488
+ * Max total wall-clock (ms) for a batch `findSignals` before it stops early.
489
+ * Keeps optional market enrichment from blocking core fixture output.
490
+ */
491
+ deadlineMs?: number;
492
+ /** Per-request fetch timeout (ms) override for the provider. */
493
+ timeoutMs?: number;
494
+ }
495
+ /**
496
+ * Result of a batch lookup. `checked` is the set of match ids the provider
497
+ * DEFINITIVELY resolved (reached the source and found no usable market, or the
498
+ * fixture is unmappable) — distinct from matches that errored or were skipped by
499
+ * the deadline. Callers negative-cache only `checked` ids, so a transient
500
+ * provider/network failure never suppresses a valid signal.
501
+ */
502
+ interface MarketSignalsResult {
503
+ signals: Map<string, MarketSignal>;
504
+ checked: Set<string>;
505
+ }
506
+ /**
507
+ * A prediction-market provider. A *separate* swap-point from ProviderAdapter
508
+ * (which supplies match data): different cadence, reliability, and legal
509
+ * posture. Implementations fetch public market data only.
510
+ */
511
+ interface MarketProvider {
512
+ readonly name: string;
513
+ /** Signal for one match, or undefined when nothing maps cleanly. */
514
+ findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
515
+ /** Batch form; signals plus the set of definitively-checked ids. */
516
+ findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
517
+ }
518
+
519
+ /**
520
+ * Probability normalization + the reliability gate. `isReliableMarketSignal` is
521
+ * the single primitive every default-on surface keys off: "show only when
522
+ * reliable, otherwise omit silently."
523
+ */
524
+
525
+ /** Default freshness window: a signal older than this is stale (15 minutes). */
526
+ declare const DEFAULT_MAX_AGE_MS: number;
527
+ /**
528
+ * Re-scale outcome probabilities so the positive ones sum to 1. This removes
529
+ * market "vig" (raw prices sum to >1) and is scale-agnostic: inputs may be
530
+ * 0..1 or 0..100 and the result is a clean [0,1] distribution. Non-finite or
531
+ * non-positive inputs collapse to 0.
532
+ */
533
+ declare function normalizeOutcomes(outcomes: MarketOutcome[]): MarketOutcome[];
534
+ /** Bucket a favorite's probability into a strength label. */
535
+ declare function favoriteStrength(probability: number): FavoriteStrength;
536
+ /** Pick the top home/draw/away outcome as the favorite, if one exists. */
537
+ declare function deriveFavorite(outcomes: MarketOutcome[]): MarketFavorite | undefined;
538
+ /**
539
+ * Does this market cleanly price the displayed home/draw/away result? Rejects
540
+ * "to advance"/"to win tournament"-style markets (an 'other' outcome), markets
541
+ * whose team codes don't match the fixture, and group-stage markets missing a
542
+ * draw line (a 90' group result must price the draw).
543
+ */
544
+ declare function mapsCleanly(match: Match, outcomes: MarketOutcome[]): boolean;
545
+ /** Sanity check: ≥2 priced outcomes whose probabilities sum to ~1. */
546
+ declare function hasSaneDistribution(outcomes: MarketOutcome[]): boolean;
547
+ /** Is the signal older than the freshness window? Unparseable timestamps are stale. */
548
+ declare function isStaleSignal(signal: MarketSignal, options?: MarketSignalOptions): boolean;
549
+ /**
550
+ * The load-bearing gate. A signal is reliable only when it is unambiguous, has
551
+ * a determinable favorite, is a sane distribution, is fresh, and (when a floor
552
+ * is configured) liquid enough. `includeUnreliable` bypasses all of it.
553
+ */
554
+ declare function isReliableMarketSignal(signal: MarketSignal, options?: MarketSignalOptions): boolean;
555
+ /** Inputs a provider supplies to build a normalized, gated signal. */
556
+ interface BuildSignalInput {
557
+ match: Match;
558
+ source: string;
559
+ sourceMarketId?: string;
560
+ asOf: string;
561
+ fetchedAt?: string;
562
+ /** Raw outcomes (any positive scale); normalized internally. */
563
+ outcomes: MarketOutcome[];
564
+ liquidity?: number;
565
+ volume24h?: number;
566
+ /** Force-flag as ambiguous (e.g. the source title didn't parse to a clean result). */
567
+ ambiguous?: boolean;
568
+ now?: Date;
569
+ maxAgeMs?: number;
570
+ }
571
+ /**
572
+ * Construct a normalized MarketSignal from a provider's raw parts. Centralizes
573
+ * normalization, clean-mapping detection, favorite derivation, and staleness so
574
+ * every provider produces identically-shaped, gate-ready signals.
575
+ */
576
+ declare function buildMarketSignal(input: BuildSignalInput): MarketSignal;
577
+
578
+ /**
579
+ * Provider-neutral text snippets for market signals — the single home for the
580
+ * approved copy bank, shared verbatim by CLI and MCP. Keeping copy here (not in
581
+ * each client) is a legal control: the language stays factual and never says
582
+ * "bet", "value", "edge", "lock", etc. Display precision is whole-number
583
+ * percent on purpose, to avoid implying false precision.
584
+ */
585
+
586
+ /** Human label for the data source (text only — never a logo). */
587
+ declare function marketSourceLabel(source: string): string;
588
+ /** One-line favorite read, drawn only from the approved copy bank. */
589
+ declare function marketFavoriteText(signal: MarketSignal, match: Match): string;
590
+ /** "Mexico 56% · Draw 25% · South Africa 19%" in home·draw·away reading order. */
591
+ declare function marketProbabilityText(signal: MarketSignal, match: Match): string;
592
+ /** "Source: Polymarket · updated 14:32 UTC". */
593
+ declare function marketAttributionText(signal: MarketSignal): string;
594
+ /** Compact one-liner for an inline annotation under a match row. */
595
+ declare function marketLine(signal: MarketSignal, match: Match): string;
596
+ /**
597
+ * Multi-line detail block (the caller indents/colorizes). Used by `match <id>`
598
+ * and the dedicated `markets` command. Always carries attribution and the
599
+ * informational-only caveat; prepends a stale warning when applicable.
600
+ */
601
+ declare function marketBlock(signal: MarketSignal, match: Match): string[];
602
+
603
+ /**
604
+ * Provider factory + graceful-degradation wrappers, mirroring `live.ts`'s
605
+ * getMatchesForDate contract: a market signal is optional enrichment, so any
606
+ * provider/network/parse error degrades to "no signal" and never throws.
607
+ */
608
+
609
+ /**
610
+ * Resolve the market-data source: explicit arg > CLAUDINHO_MARKETS_SOURCE env >
611
+ * 'polymarket' (mirrors resolveCompetition). Set CLAUDINHO_MARKETS_SOURCE=fake
612
+ * to preview the UX with synthetic, clearly-labeled "demo data" odds.
613
+ */
614
+ declare function resolveMarketSource(explicit?: string): string;
615
+ /**
616
+ * Construct a market-signal provider. Defaults to the Polymarket public-data
617
+ * adapter; honors CLAUDINHO_MARKETS_SOURCE ('fake' = network-free synthetic
618
+ * demo data; 'none'/'off' = network-free no-op). Tests usually inject directly.
619
+ */
620
+ declare function makeMarketProvider(source?: string): MarketProvider;
621
+ /** Fetch one match's signal; never throws — undefined on any error. */
622
+ declare function getMarketSignal(provider: MarketProvider, match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
623
+ /** Batch fetch; never throws — empty result (nothing checked) on any error. */
624
+ declare function getMarketSignals(provider: MarketProvider, matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
625
+
626
+ /**
627
+ * A network-free MarketProvider for tests and local UX validation. Returns
628
+ * explicitly-provided signals and (optionally) deterministically synthesizes
629
+ * plausible-but-fake odds so every surface can be exercised without any live
630
+ * API. Always labeled `source: 'fake'` so it can never masquerade as real data.
631
+ */
632
+
633
+ interface FakeMarketProviderOptions {
634
+ /** Pre-built signals keyed by matchId; returned verbatim when present. */
635
+ signals?: Record<string, MarketSignal>;
636
+ /** When true, synthesize a deterministic signal for any unmapped match. */
637
+ synthesize?: boolean;
638
+ /** "Now" used when synthesizing timestamps (keeps tests deterministic). */
639
+ now?: Date;
640
+ }
641
+ declare class FakeMarketProvider implements MarketProvider {
642
+ private readonly opts;
643
+ readonly name = "fake";
644
+ constructor(opts?: FakeMarketProviderOptions);
645
+ findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
646
+ findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
647
+ private synthesize;
648
+ }
649
+
650
+ /**
651
+ * Polymarket public-data adapter — read-only prediction-market signals.
652
+ *
653
+ * STRICT read-only by design: it touches only the public Gamma events/markets
654
+ * data endpoint. No auth, no wallet, no CLOB/order endpoints, no trading, and no
655
+ * outbound links (sourceMarketId is opaque, never a URL). Any network/parse/host
656
+ * error degrades to "no signal" — it never throws.
657
+ *
658
+ * Payload model (verified against the live Gamma API, see
659
+ * docs/POLYMARKET_MARKET_PREDICTIONS.md): a World Cup match is a Gamma EVENT
660
+ * (`fifwc-{home}-{away}-{date}`) whose payload carries the three moneyline
661
+ * BINARY markets — home win / draw / away win. Each is `outcomes: ["Yes","No"]`
662
+ * and the outcome's probability is its "Yes" price.
663
+ *
664
+ * Mapping is by event slug, which is deterministic — so by default the slug is
665
+ * DERIVED from the fixture (team codes + UTC date) and every match attempts
666
+ * enrichment. A hand-curated entry in mapping.2026.json overrides the derived
667
+ * slug for the rare fixture whose slug doesn't follow the pattern. Because the
668
+ * slug is a guess, validation FAILS CLOSED: the returned event must match the
669
+ * requested slug, line up on kickoff, expose the right moneyline markets, and
670
+ * resolve in regular time — otherwise no signal is produced.
671
+ */
672
+
673
+ /**
674
+ * Optional override of the derived event slug for a fixture whose Polymarket
675
+ * slug doesn't follow `fifwc-{home}-{away}-{date}` (e.g. an abbreviation that
676
+ * differs from the FIFA code). Most matches need no entry — the slug is derived.
677
+ */
678
+ interface MarketMapping {
679
+ /** Gamma event slug, e.g. "fifwc-mex-rsa-2026-06-11". */
680
+ eventSlug: string;
681
+ /** Optional Gamma event id (diagnostics; the slug is the lookup key). */
682
+ eventId?: string;
683
+ }
684
+ type MarketMappingTable = Record<string, MarketMapping>;
685
+ interface PolymarketProviderOptions {
686
+ fetchImpl?: typeof fetch;
687
+ timeoutMs?: number;
688
+ /** Base URL; must resolve to an allow-listed host. */
689
+ baseUrl?: string;
690
+ /** Override the bundled mapping table (tests / future gateway). */
691
+ mapping?: MarketMappingTable;
692
+ now?: Date;
693
+ maxAgeMs?: number;
694
+ }
695
+ declare class PolymarketProvider implements MarketProvider {
696
+ private readonly opts;
697
+ readonly name = "polymarket";
698
+ constructor(opts?: PolymarketProviderOptions);
699
+ findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
700
+ findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
701
+ /**
702
+ * Resolve one match. `checked` distinguishes a DEFINITIVE result (reached the
703
+ * source and found no usable market, or the fixture is unmappable) from a
704
+ * provider/network error — so transient failures are retried, not
705
+ * negative-cached.
706
+ */
707
+ private resolveOne;
708
+ private fetchEvent;
709
+ private toSignal;
710
+ }
711
+
712
+ export { type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchEvent, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, type Stage, type StandingRow, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, flagEmoji, formatKickoff, getLiveMatches, getMarketSignal, getMarketSignals, getMatchesForDate, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, localDate, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketLine, marketProbabilityText, marketSourceLabel, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeOutcomes, outcomeFromScore, resolveCompetition, resolveMarketSource, resolveTz, scoreline };
package/dist/index.js CHANGED
@@ -2848,12 +2848,16 @@ function mergeLive(base, live) {
2848
2848
  for (const m of live) byId.set(m.id, m);
2849
2849
  return [...byId.values()];
2850
2850
  }
2851
+ function liveSourceLabel(source) {
2852
+ const known = { espn: "ESPN" };
2853
+ return known[source] ?? source.charAt(0).toUpperCase() + source.slice(1);
2854
+ }
2851
2855
  async function getMatchesForDate(adapter, dateISO) {
2852
2856
  const base = allFixtures();
2853
2857
  const day = dateISO.slice(0, 10);
2854
2858
  try {
2855
2859
  const live = adapter.fetchWindow ? await adapter.fetchWindow(shiftUtcDate(day, -1), shiftUtcDate(day, 1)) : await adapter.fetchByDate(day);
2856
- return { matches: mergeLive(base, live), degraded: false };
2860
+ return { matches: mergeLive(base, live), degraded: false, source: adapter.name };
2857
2861
  } catch {
2858
2862
  return { matches: base, degraded: true };
2859
2863
  }
@@ -2864,46 +2868,501 @@ function shiftUtcDate(dateISO, days) {
2864
2868
  }
2865
2869
  async function getLiveMatches(adapter) {
2866
2870
  try {
2867
- return { matches: await adapter.fetchLive(), degraded: false };
2871
+ return { matches: await adapter.fetchLive(), degraded: false, source: adapter.name };
2868
2872
  } catch {
2869
2873
  return { matches: [], degraded: true };
2870
2874
  }
2871
2875
  }
2876
+
2877
+ // src/markets/normalize.ts
2878
+ var DEFAULT_MAX_AGE_MS = 15 * 6e4;
2879
+ function normalizeOutcomes(outcomes) {
2880
+ const sum = outcomes.reduce(
2881
+ (s, o) => s + (Number.isFinite(o.probability) && o.probability > 0 ? o.probability : 0),
2882
+ 0
2883
+ );
2884
+ if (sum <= 0) return outcomes.map((o) => ({ ...o, probability: 0 }));
2885
+ return outcomes.map((o) => ({
2886
+ ...o,
2887
+ probability: Number.isFinite(o.probability) && o.probability > 0 ? o.probability / sum : 0
2888
+ }));
2889
+ }
2890
+ function favoriteStrength(probability) {
2891
+ if (probability >= 0.65) return "clear";
2892
+ if (probability >= 0.52) return "slight";
2893
+ return "close";
2894
+ }
2895
+ function deriveFavorite(outcomes) {
2896
+ let top;
2897
+ for (const o of outcomes) {
2898
+ if (o.kind === "other") continue;
2899
+ if (!top || o.probability > top.probability) top = o;
2900
+ }
2901
+ if (!top || top.probability <= 0 || top.kind === "other") return void 0;
2902
+ return {
2903
+ kind: top.kind,
2904
+ teamCode: top.teamCode,
2905
+ probability: top.probability,
2906
+ strength: favoriteStrength(top.probability)
2907
+ };
2908
+ }
2909
+ function mapsCleanly(match, outcomes) {
2910
+ if (outcomes.some((o) => o.kind === "other")) return false;
2911
+ const home = outcomes.find((o) => o.kind === "home");
2912
+ const away = outcomes.find((o) => o.kind === "away");
2913
+ const draw = outcomes.find((o) => o.kind === "draw");
2914
+ if (!home || !away) return false;
2915
+ if (home.teamCode && home.teamCode.toUpperCase() !== match.home.code.toUpperCase()) {
2916
+ return false;
2917
+ }
2918
+ if (away.teamCode && away.teamCode.toUpperCase() !== match.away.code.toUpperCase()) {
2919
+ return false;
2920
+ }
2921
+ if (match.stage === "GROUP" && !draw) return false;
2922
+ return true;
2923
+ }
2924
+ function hasSaneDistribution(outcomes) {
2925
+ const priced = outcomes.filter((o) => Number.isFinite(o.probability) && o.probability > 0);
2926
+ if (priced.length < 2) return false;
2927
+ const sum = priced.reduce((s, o) => s + o.probability, 0);
2928
+ return sum > 0.97 && sum < 1.03;
2929
+ }
2930
+ function isStaleSignal(signal, options = {}) {
2931
+ const maxAge = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2932
+ const asOf = Date.parse(signal.asOf);
2933
+ if (!Number.isFinite(asOf)) return true;
2934
+ const now = (options.now ?? /* @__PURE__ */ new Date()).getTime();
2935
+ return now - asOf > maxAge;
2936
+ }
2937
+ function isReliableMarketSignal(signal, options = {}) {
2938
+ if (options.includeUnreliable) return true;
2939
+ if (signal.ambiguous) return false;
2940
+ if (!signal.favorite) return false;
2941
+ if (!hasSaneDistribution(signal.outcomes)) return false;
2942
+ if (signal.stale || isStaleSignal(signal, options)) return false;
2943
+ if (options.minLiquidity != null) {
2944
+ if (signal.liquidity == null || signal.liquidity < options.minLiquidity) return false;
2945
+ }
2946
+ return true;
2947
+ }
2948
+ function buildMarketSignal(input) {
2949
+ const outcomes = normalizeOutcomes(input.outcomes);
2950
+ const ambiguous = input.ambiguous === true || !mapsCleanly(input.match, outcomes);
2951
+ const favorite = ambiguous ? void 0 : deriveFavorite(outcomes);
2952
+ const signal = {
2953
+ matchId: input.match.id,
2954
+ source: input.source,
2955
+ sourceMarketId: input.sourceMarketId,
2956
+ asOf: input.asOf,
2957
+ fetchedAt: input.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2958
+ outcomes,
2959
+ favorite,
2960
+ liquidity: input.liquidity,
2961
+ volume24h: input.volume24h,
2962
+ stale: false,
2963
+ ambiguous
2964
+ };
2965
+ signal.stale = isStaleSignal(signal, { now: input.now, maxAgeMs: input.maxAgeMs });
2966
+ return signal;
2967
+ }
2968
+
2969
+ // src/markets/format.ts
2970
+ function pct(p) {
2971
+ return Math.round(p * 100);
2972
+ }
2973
+ function marketSourceLabel(source) {
2974
+ if (source === "polymarket") return "Polymarket";
2975
+ if (source === "fake") return "demo data";
2976
+ return source.charAt(0).toUpperCase() + source.slice(1);
2977
+ }
2978
+ function outcomeLabel(o, match) {
2979
+ if (o.kind === "home") return match.home.name;
2980
+ if (o.kind === "away") return match.away.name;
2981
+ if (o.kind === "draw") return "Draw";
2982
+ return o.label;
2983
+ }
2984
+ function utcHhmm(iso) {
2985
+ const t = Date.parse(iso);
2986
+ if (!Number.isFinite(t)) return "";
2987
+ return `${new Date(t).toISOString().slice(11, 16)} UTC`;
2988
+ }
2989
+ function marketFavoriteText(signal, match) {
2990
+ const fav = signal.favorite;
2991
+ if (!fav || fav.strength === "close") return "Prediction markets see this match as close.";
2992
+ if (fav.kind === "draw") return "Prediction markets see a draw as the top outcome.";
2993
+ const name = fav.kind === "home" ? match.home.name : match.away.name;
2994
+ return fav.strength === "clear" ? `Prediction markets favor ${name}.` : `Prediction markets slightly favor ${name}.`;
2995
+ }
2996
+ function marketProbabilityText(signal, match) {
2997
+ const order = ["home", "draw", "away"];
2998
+ const parts = [];
2999
+ for (const kind of order) {
3000
+ const o = signal.outcomes.find((x) => x.kind === kind);
3001
+ if (o) parts.push(`${outcomeLabel(o, match)} ${pct(o.probability)}%`);
3002
+ }
3003
+ for (const o of signal.outcomes) {
3004
+ if (o.kind === "other") parts.push(`${outcomeLabel(o, match)} ${pct(o.probability)}%`);
3005
+ }
3006
+ return parts.join(" \xB7 ");
3007
+ }
3008
+ function marketAttributionText(signal) {
3009
+ const time = utcHhmm(signal.asOf);
3010
+ const src = `Source: ${marketSourceLabel(signal.source)}`;
3011
+ return time ? `${src} \xB7 updated ${time}` : src;
3012
+ }
3013
+ function marketLine(signal, match) {
3014
+ return `Market: ${marketProbabilityText(signal, match)} \xB7 ${marketSourceLabel(
3015
+ signal.source
3016
+ )} \xB7 informational only`;
3017
+ }
3018
+ function marketBlock(signal, match) {
3019
+ const lines = [];
3020
+ if (signal.stale) lines.push("Market signal is stale; the reading may be out of date.");
3021
+ lines.push(marketFavoriteText(signal, match));
3022
+ lines.push(marketProbabilityText(signal, match));
3023
+ lines.push(`${marketAttributionText(signal)} \xB7 informational only`);
3024
+ return lines;
3025
+ }
3026
+
3027
+ // src/markets/fake.ts
3028
+ var FakeMarketProvider = class {
3029
+ constructor(opts = {}) {
3030
+ this.opts = opts;
3031
+ }
3032
+ opts;
3033
+ name = "fake";
3034
+ async findSignal(match, options) {
3035
+ const preset = this.opts.signals?.[match.id];
3036
+ if (preset) return preset;
3037
+ if (this.opts.synthesize) return this.synthesize(match, options);
3038
+ return void 0;
3039
+ }
3040
+ async findSignals(matches, options) {
3041
+ const signals = /* @__PURE__ */ new Map();
3042
+ const checked = /* @__PURE__ */ new Set();
3043
+ for (const m of matches) {
3044
+ checked.add(m.id);
3045
+ const s = await this.findSignal(m, options);
3046
+ if (s) signals.set(m.id, s);
3047
+ }
3048
+ return { signals, checked };
3049
+ }
3050
+ synthesize(match, options) {
3051
+ const seed = hash(`${match.home.code}-${match.away.code}`);
3052
+ const home = 0.3 + seed % 33 / 100;
3053
+ const away = 0.18 + (seed >> 3) % 23 / 100;
3054
+ const draw = Math.max(0.05, 1 - home - away);
3055
+ const outcomes = [
3056
+ { kind: "home", teamCode: match.home.code, label: match.home.name, probability: home },
3057
+ { kind: "draw", label: "Draw", probability: draw },
3058
+ { kind: "away", teamCode: match.away.code, label: match.away.name, probability: away }
3059
+ ];
3060
+ const now = this.opts.now ?? options?.now ?? /* @__PURE__ */ new Date();
3061
+ const asOf = new Date(now.getTime() - 6e4).toISOString();
3062
+ return buildMarketSignal({
3063
+ match,
3064
+ source: "fake",
3065
+ sourceMarketId: `fake-${match.id}`,
3066
+ asOf,
3067
+ fetchedAt: now.toISOString(),
3068
+ outcomes,
3069
+ liquidity: 5e4,
3070
+ now,
3071
+ maxAgeMs: options?.maxAgeMs
3072
+ });
3073
+ }
3074
+ };
3075
+ function hash(s) {
3076
+ let h = 0;
3077
+ for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) >>> 0;
3078
+ return h % 1e5;
3079
+ }
3080
+
3081
+ // src/markets/mapping.2026.json
3082
+ var mapping_2026_default = {
3083
+ version: 1,
3084
+ note: "Optional matchId -> Polymarket EVENT-slug OVERRIDES. By default the slug is DERIVED from each fixture (fifwc-{home}-{away}-{UTC-date}, e.g. fifwc-mex-rsa-2026-06-11), so most matches need NO entry here. Add an entry only for a fixture whose real Polymarket slug differs (e.g. a team abbreviation that isn't the FIFA code, or a tz-shifted date). The event payload carries the three moneyline binary markets; each outcome's probability is its 'Yes' price; validation fails closed. Entry: { eventSlug, eventId? }.",
3085
+ markets: {}
3086
+ };
3087
+
3088
+ // src/markets/polymarket.ts
3089
+ var DEFAULT_BASE2 = "https://gamma-api.polymarket.com";
3090
+ var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["gamma-api.polymarket.com"]);
3091
+ var USER_AGENT2 = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
3092
+ var DEFAULT_TIMEOUT_MS = 8e3;
3093
+ var WC_SERIES_SLUG = "soccer-fifwc";
3094
+ var WC_SPORT = "fifwc";
3095
+ var KICKOFF_TOLERANCE_MS = 6 * 60 * 6e4;
3096
+ var NON_REGULAR_TIME = /extra time|penalt|to advance|to qualif|win the (group|tournament|cup|title)/i;
3097
+ var BUNDLED_MAPPING = mapping_2026_default.markets;
3098
+ var PolymarketProvider = class {
3099
+ constructor(opts = {}) {
3100
+ this.opts = opts;
3101
+ }
3102
+ opts;
3103
+ name = "polymarket";
3104
+ async findSignal(match, options) {
3105
+ return (await this.resolveOne(match, options)).signal;
3106
+ }
3107
+ async findSignals(matches, options) {
3108
+ const signals = /* @__PURE__ */ new Map();
3109
+ const checked = /* @__PURE__ */ new Set();
3110
+ const deadline = options?.deadlineMs != null ? Date.now() + options.deadlineMs : Number.POSITIVE_INFINITY;
3111
+ for (const m of matches) {
3112
+ if (Date.now() >= deadline) break;
3113
+ const r = await this.resolveOne(m, options);
3114
+ if (r.checked) checked.add(m.id);
3115
+ if (r.signal) signals.set(m.id, r.signal);
3116
+ }
3117
+ return { signals, checked };
3118
+ }
3119
+ /**
3120
+ * Resolve one match. `checked` distinguishes a DEFINITIVE result (reached the
3121
+ * source and found no usable market, or the fixture is unmappable) from a
3122
+ * provider/network error — so transient failures are retried, not
3123
+ * negative-cached.
3124
+ */
3125
+ async resolveOne(match, options) {
3126
+ const entry = (this.opts.mapping ?? BUNDLED_MAPPING)[match.id];
3127
+ const eventSlug = entry?.eventSlug ?? deriveEventSlug(match);
3128
+ if (!eventSlug) return { checked: true };
3129
+ try {
3130
+ const event = await this.fetchEvent(eventSlug, options?.timeoutMs);
3131
+ const signal = event ? this.toSignal(match, eventSlug, event, options) : void 0;
3132
+ return { signal, checked: true };
3133
+ } catch {
3134
+ return { checked: false };
3135
+ }
3136
+ }
3137
+ async fetchEvent(slug, timeoutMs) {
3138
+ const base = this.opts.baseUrl ?? DEFAULT_BASE2;
3139
+ assertAllowedHost(base);
3140
+ const url = `${base}/events?slug=${encodeURIComponent(slug)}`;
3141
+ const doFetch = this.opts.fetchImpl ?? fetch;
3142
+ const res = await doFetch(url, {
3143
+ signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
3144
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT2 }
3145
+ });
3146
+ if (res.status === 404) return void 0;
3147
+ if (!res.ok) {
3148
+ throw new Error(`Polymarket request failed: ${res.status} ${res.statusText}`);
3149
+ }
3150
+ const data = await res.json();
3151
+ const event = Array.isArray(data) ? data[0] : data;
3152
+ return event && typeof event === "object" ? event : void 0;
3153
+ }
3154
+ toSignal(match, eventSlug, event, options) {
3155
+ if (event.active === false || event.closed === true) return void 0;
3156
+ if (event.seriesSlug != null && event.seriesSlug !== WC_SERIES_SLUG && event.sport?.sport !== WC_SPORT) {
3157
+ return void 0;
3158
+ }
3159
+ if (event.slug != null && event.slug !== eventSlug) return void 0;
3160
+ const start = event.startTime ? Date.parse(event.startTime) : Number.NaN;
3161
+ const kick = Date.parse(match.kickoff);
3162
+ if (Number.isFinite(start) && Number.isFinite(kick) && Math.abs(start - kick) > KICKOFF_TOLERANCE_MS) {
3163
+ return void 0;
3164
+ }
3165
+ const moneyline = (event.markets ?? []).filter(
3166
+ (m) => (m.sportsMarketType ?? "moneyline") === "moneyline"
3167
+ );
3168
+ const homeMarket = pickMarket(moneyline, match.home.code, match.home.name);
3169
+ const awayMarket = pickMarket(moneyline, match.away.code, match.away.name);
3170
+ const drawMarket = pickDraw(moneyline);
3171
+ if (!homeMarket || !awayMarket) return void 0;
3172
+ const legIds = [homeMarket, awayMarket, drawMarket].filter((m) => m != null).map((m) => m.id ?? m.slug ?? "");
3173
+ if (new Set(legIds).size !== legIds.length) return void 0;
3174
+ const legs = [
3175
+ ["home", homeMarket, match.home.code, match.home.name],
3176
+ ["draw", drawMarket, void 0, "Draw"],
3177
+ ["away", awayMarket, match.away.code, match.away.name]
3178
+ ];
3179
+ const outcomes = [];
3180
+ let asOf = event.updatedAt;
3181
+ let liquidity;
3182
+ for (const [kind, market, teamCode, label] of legs) {
3183
+ if (!market) continue;
3184
+ if (market.closed === true || market.active === false) return void 0;
3185
+ if (market.description && NON_REGULAR_TIME.test(market.description)) return void 0;
3186
+ const yes = yesPrice(market);
3187
+ if (yes == null) return void 0;
3188
+ outcomes.push({ kind, teamCode, label, probability: yes });
3189
+ if (market.updatedAt && (!asOf || market.updatedAt < asOf)) asOf = market.updatedAt;
3190
+ const liq = numberish(market.liquidityNum ?? market.liquidity);
3191
+ if (liq != null) liquidity = liquidity == null ? liq : Math.min(liquidity, liq);
3192
+ }
3193
+ const rawSum = outcomes.reduce((s, o) => s + o.probability, 0);
3194
+ if (rawSum < 0.9 || rawSum > 1.15) return void 0;
3195
+ const signal = buildMarketSignal({
3196
+ match,
3197
+ source: "polymarket",
3198
+ sourceMarketId: event.id ?? eventSlug,
3199
+ asOf: asOf ?? (/* @__PURE__ */ new Date()).toISOString(),
3200
+ outcomes,
3201
+ liquidity,
3202
+ now: options?.now ?? this.opts.now,
3203
+ maxAgeMs: options?.maxAgeMs ?? this.opts.maxAgeMs
3204
+ });
3205
+ return signal.ambiguous ? void 0 : signal;
3206
+ }
3207
+ };
3208
+ function deriveEventSlug(match) {
3209
+ const home = match.home.code.toLowerCase();
3210
+ const away = match.away.code.toLowerCase();
3211
+ if (home === away || home === "tbd" || away === "tbd") return void 0;
3212
+ if (!/^[a-z]{3}$/.test(home) || !/^[a-z]{3}$/.test(away)) return void 0;
3213
+ const date = match.kickoff.slice(0, 10);
3214
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return void 0;
3215
+ return `fifwc-${home}-${away}-${date}`;
3216
+ }
3217
+ function slugToken(m) {
3218
+ return (m.slug ?? "").toLowerCase().split("-").pop() ?? "";
3219
+ }
3220
+ function isDrawMarket(m) {
3221
+ return slugToken(m) === "draw" || (m.groupItemTitle ?? "").trim().toLowerCase().startsWith("draw");
3222
+ }
3223
+ function pickMarket(markets, teamCode, teamName) {
3224
+ const code = teamCode.toLowerCase();
3225
+ const name = teamName.trim().toLowerCase();
3226
+ const teamMarkets = markets.filter((m) => !isDrawMarket(m));
3227
+ const bySlug = teamMarkets.find((m) => slugToken(m) === code);
3228
+ if (bySlug) return bySlug;
3229
+ return teamMarkets.find((m) => (m.groupItemTitle ?? "").trim().toLowerCase() === name);
3230
+ }
3231
+ function pickDraw(markets) {
3232
+ return markets.find(isDrawMarket);
3233
+ }
3234
+ function assertAllowedHost(base) {
3235
+ let host;
3236
+ try {
3237
+ host = new URL(base).host;
3238
+ } catch {
3239
+ throw new Error(`Invalid Polymarket base URL: ${base}`);
3240
+ }
3241
+ if (!ALLOWED_HOSTS.has(host)) {
3242
+ throw new Error(`Polymarket host not allow-listed: ${host}`);
3243
+ }
3244
+ }
3245
+ function yesPrice(market) {
3246
+ const labels = parseJsonArray(market.outcomes);
3247
+ const prices = parseJsonArray(market.outcomePrices).map((p2) => Number(p2));
3248
+ if (labels.length === 0 || labels.length !== prices.length) return void 0;
3249
+ const i = labels.findIndex((l) => l.trim().toLowerCase() === "yes");
3250
+ if (i < 0) return void 0;
3251
+ const p = prices[i];
3252
+ return typeof p === "number" && Number.isFinite(p) && p > 0 && p <= 1 ? p : void 0;
3253
+ }
3254
+ function parseJsonArray(v) {
3255
+ if (Array.isArray(v)) return v.map((x) => String(x));
3256
+ if (typeof v === "string") {
3257
+ try {
3258
+ const parsed = JSON.parse(v);
3259
+ return Array.isArray(parsed) ? parsed.map((x) => String(x)) : [];
3260
+ } catch {
3261
+ return [];
3262
+ }
3263
+ }
3264
+ return [];
3265
+ }
3266
+ function numberish(v) {
3267
+ if (typeof v === "number") return Number.isFinite(v) ? v : void 0;
3268
+ if (typeof v === "string") {
3269
+ const n = Number(v);
3270
+ return Number.isFinite(n) ? n : void 0;
3271
+ }
3272
+ return void 0;
3273
+ }
3274
+
3275
+ // src/markets/provider.ts
3276
+ function resolveMarketSource(explicit) {
3277
+ if (explicit) return explicit;
3278
+ if (typeof process !== "undefined" && process.env?.CLAUDINHO_MARKETS_SOURCE) {
3279
+ return process.env.CLAUDINHO_MARKETS_SOURCE;
3280
+ }
3281
+ return "polymarket";
3282
+ }
3283
+ function makeMarketProvider(source) {
3284
+ switch (resolveMarketSource(source)) {
3285
+ case "fake":
3286
+ return new FakeMarketProvider({ synthesize: true });
3287
+ case "none":
3288
+ case "off":
3289
+ return new FakeMarketProvider();
3290
+ // no synth → yields no signals, no network
3291
+ default:
3292
+ return new PolymarketProvider();
3293
+ }
3294
+ }
3295
+ async function getMarketSignal(provider, match, options) {
3296
+ try {
3297
+ return await provider.findSignal(match, options);
3298
+ } catch {
3299
+ return void 0;
3300
+ }
3301
+ }
3302
+ async function getMarketSignals(provider, matches, options) {
3303
+ try {
3304
+ return await provider.findSignals(matches, options);
3305
+ } catch {
3306
+ return { signals: /* @__PURE__ */ new Map(), checked: /* @__PURE__ */ new Set() };
3307
+ }
3308
+ }
2872
3309
  export {
2873
3310
  DEFAULT_COMPETITION,
2874
3311
  DEFAULT_FLAVOR,
3312
+ DEFAULT_MAX_AGE_MS,
2875
3313
  EspnAdapter,
2876
3314
  FLAVOR_LEVELS,
3315
+ FakeMarketProvider,
3316
+ PolymarketProvider,
2877
3317
  allFixtures,
2878
3318
  asFlavorLevel,
3319
+ buildMarketSignal,
2879
3320
  byKickoff,
2880
3321
  competitionBase,
2881
3322
  computeStandings,
2882
3323
  countdown,
3324
+ deriveFavorite,
3325
+ favoriteStrength,
2883
3326
  fixturesByDate,
2884
3327
  fixturesByGroup,
2885
3328
  fixturesByTeam,
2886
3329
  flagEmoji,
2887
3330
  formatKickoff,
2888
3331
  getLiveMatches,
3332
+ getMarketSignal,
3333
+ getMarketSignals,
2889
3334
  getMatchesForDate,
2890
3335
  groups,
3336
+ hasSaneDistribution,
2891
3337
  isFinished,
2892
3338
  isFlavorLevel,
2893
3339
  isLive,
3340
+ isReliableMarketSignal,
3341
+ isStaleSignal,
2894
3342
  isValidDate,
2895
3343
  isValidTimeZone,
3344
+ liveSourceLabel,
2896
3345
  localDate,
2897
3346
  makeAdapter,
3347
+ makeMarketProvider,
2898
3348
  mapEspnEvent,
3349
+ mapsCleanly,
3350
+ marketAttributionText,
3351
+ marketBlock,
3352
+ marketFavoriteText,
3353
+ marketLine,
3354
+ marketProbabilityText,
3355
+ marketSourceLabel,
2899
3356
  matchFlavor,
2900
3357
  matchLocation,
2901
3358
  mergeLive,
2902
3359
  nationToFlag,
2903
3360
  nationToRegion,
2904
3361
  nextFixtureForTeam,
3362
+ normalizeOutcomes,
2905
3363
  outcomeFromScore,
2906
3364
  resolveCompetition,
3365
+ resolveMarketSource,
2907
3366
  resolveTz,
2908
3367
  scoreline
2909
3368
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@claudinho/core",
3
- "version": "0.2.0",
4
- "description": "Domain model, data-provider adapters, and helpers for Claudinho — the 2026 football tournament in your dev environment.",
3
+ "version": "0.3.0",
4
+ "description": "Domain model, provider adapters, standings, and a read-only market-signal sidecar for Claudinho — the 2026 men's football tournament. Not affiliated with FIFA or Anthropic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Arturo Garrido",