JS8 v3.1 — restored guards

Plak dit in de node LS — JS8 ADAPTIVE BROKER PICKER in n8n. Dit is v3.0 (Supabase thresholds) mét de teruggezette per-kanaal scoregewichten, channel-success-floor, divergentie-plafond en consensus-guard.

1005 regels

// ===== JS8 — FIXTURE BROKER v3.1 RESTORED GUARDS (Supabase thresholds + per-channel ranking) =====
// Doel:
// - v2.8 behouden
// - NIEUW: underdog DC / underdog uitslag tegen een DUIDELIJKE markt-favoriet
//   blokkeren op basis van de ontviggede markt-kans (>= 0.55), ook als data
//   NIET dun is en de prediction wel bestaat (Valerenga-Aalesund type fix)
// - model_only / missing prediction strenger behandelen (v2.7)
// - underdog DC tegen bookmaker-favoriet blokkeren bij dunne data
// - SAFE niet onnodig slopen
// - output compatible met bestaande flow

const items = $input.all();

function asNum(v, fb = null) {
  const n = Number(v);
  return Number.isFinite(n) ? n : fb;
}

function arr(v) {
  return Array.isArray(v) ? v : [];
}

function r2(v) {
  const n = asNum(v, 0);
  return Math.round(n * 100) / 100;
}

function r3(v) {
  const n = asNum(v, 0);
  return Math.round(n * 1000) / 1000;
}

function uniq(list) {
  return [...new Set(arr(list).filter(Boolean))];
}

function lc(v) {
  return String(v ?? "").toLowerCase();
}

function isRisk(strategy) {
  return strategy === "risk";
}

function isValue(strategy) {
  return strategy === "value";
}

function getStrategy(j) {
  const s = lc(j.strategy_key ?? j.telegram_channel ?? j.channel ?? "safe");
  if (s === "risk") return "risk";
  if (s === "value") return "value";
  return "safe";
}

function isModelOnly(j) {
  return (
    j.prediction_source === "model_only" ||
    j.prediction_available === false ||
    j.prediction_status === "missing" ||
    j.data_quality?.model_only === true ||
    j.data_quality?.prediction_missing === true
  );
}

function hasVeryThinData(j) {
  return (
    j.data_quality?.thin_last_home === true &&
    j.data_quality?.thin_last_away === true &&
    j.data_quality?.thin_h2h === true &&
    j.data_quality?.standings_missing === true
  );
}

function marketKeyFor(market, value) {
  const v = String(value ?? "");

  if (market === "result") {
    if (v === "Home") return "result_home";
    if (v === "Draw") return "result_draw";
    if (v === "Away") return "result_away";
  }

  if (market === "double_chance") {
    if (v === "Home/Draw") return "double_chance_home";
    if (v === "Home/Away") return "double_chance_12";
    if (v === "Draw/Away") return "double_chance_away";
  }

  if (market === "over_under") {
    return "ou_" + v.toLowerCase().replace(/\s+/g, "_").replace(".", "_");
  }

  if (market === "btts") {
    return v === "Yes" ? "btts_yes" : "btts_no";
  }

  return `${market}_${v}`.toLowerCase().replace(/\s+/g, "_");
}

function labelFor(j, market, value) {
  if (market === "result") {
    if (value === "Home") return `${j.homeTeam ?? "Home"} wint`;
    if (value === "Away") return `${j.awayTeam ?? "Away"} wint`;
    return "Gelijkspel";
  }

  if (market === "double_chance") {
    if (value === "Home/Draw") return `${j.homeTeam ?? "Home"} of Gelijk (1X)`;
    if (value === "Draw/Away") return `Gelijk of ${j.awayTeam ?? "Away"} (X2)`;
    if (value === "Home/Away") return `${j.homeTeam ?? "Home"} of ${j.awayTeam ?? "Away"} (12)`;
  }

  if (market === "btts") return value === "Yes" ? "BTTS Ja" : "BTTS Nee";

  return String(value ?? "-");
}

function isPreferred(j, marketKey) {
  const prefs = arr(j.preferred_markets).map(lc);
  return prefs.includes(lc(marketKey));
}

function isAvoid(j, marketKey) {
  const avoids = arr(j.avoid_markets).map(lc);
  return avoids.includes(lc(marketKey));
}

function getModelProb(j, market, value) {
  return asNum(j?.model_probs?.[market]?.[value], null);
}

function fairBookProbFromBets(j, market, value, odd) {
  const o = asNum(odd, null);
  if (!o || o <= 1) return null;

  const bets = arr(j?.bets?.[market]);
  const probs = [];

  for (const b of bets) {
    const bo = asNum(b.odd, null);
    if (bo && bo > 1) probs.push({ value: String(b.value), p: 1 / bo });
  }

  const sum = probs.reduce((a, b) => a + b.p, 0);
  const found = probs.find(x => x.value === String(value));

  if (found && sum > 0 && ["result", "over_under", "btts"].includes(market)) {
    return found.p / sum;
  }

  return 1 / o;
}

function getResultOdd(j, value) {
  const found = arr(j?.bets?.result).find(x => String(x.value) === value);
  return asNum(found?.odd, null);
}

function recentStats(list, maxN = 5) {
  const games = arr(list).slice(0, maxN);
  let used = 0;
  let scoredZero = 0;
  let cleanSheets = 0;
  let gf = 0;
  let ga = 0;
  let bttsYes = 0;

  for (const m of games) {
    const gFor = asNum(m?.gf, null);
    const gAgainst = asNum(m?.ga, null);
    if (gFor == null || gAgainst == null) continue;

    used++;
    gf += gFor;
    ga += gAgainst;
    if (gFor === 0) scoredZero++;
    if (gAgainst === 0) cleanSheets++;
    if (gFor > 0 && gAgainst > 0) bttsYes++;
  }

  return {
    used,
    gf_pg: used ? gf / used : null,
    ga_pg: used ? ga / used : null,
    scored_zero: scoredZero,
    clean_sheets: cleanSheets,
    btts_yes: bttsYes,
  };
}

function getRecentContext(j) {
  const homeRecent = arr(j?.HOME?.home_last5).length
    ? arr(j?.HOME?.home_last5)
    : arr(j?.HOME?.home_last10);

  const awayRecent = arr(j?.AWAY?.away_last5).length
    ? arr(j?.AWAY?.away_last5)
    : arr(j?.AWAY?.away_last10);

  return {
    home: recentStats(homeRecent, 5),
    away: recentStats(awayRecent, 5),
  };
}

function h2hBttsStats(j) {
  const list = arr(j?.h2h?.list);
  let used = 0;
  let yes = 0;
  let no = 0;

  for (const m of list) {
    const hg = asNum(m?.goals_home, null);
    const ag = asNum(m?.goals_away, null);
    if (hg == null || ag == null) continue;

    used++;
    if (hg > 0 && ag > 0) yes++;
    else no++;
  }

  return {
    used,
    yes,
    no,
    yes_rate: used ? yes / used : null,
    no_rate: used ? no / used : null,
  };
}

function normalizeExistingBet(j, b) {
  const odd = asNum(b.odd, null);
  if (!odd || odd <= 1) return null;

  const market = b.market ?? null;
  const value = b.value ?? null;
  const marketKey = b.market_key ?? marketKeyFor(market, value);

  const bookP = asNum(b.book_p, null);
  const modelP =
    asNum(b.model_p_cal, null) ??
    asNum(b.model_p, null) ??
    asNum(b.model_p_raw, null);

  if (modelP == null) return null;

  const bp = bookP ?? fairBookProbFromBets(j, market, value, odd) ?? (1 / odd);

  const ev = ((modelP * odd) - 1) * 100;
  const edge = (modelP - bp) * 100;

  return {
    ...b,
    market,
    market_key: marketKey,
    value,
    label: b.label ?? labelFor(j, market, value),
    odd,
    book_p: r3(bp),
    model_p_raw: r3(asNum(b.model_p_raw, modelP)),
    model_p: r3(modelP),
    model_p_cal: r3(modelP),
    edge_raw: r2(asNum(b.edge_raw, edge)),
    edge: r2(edge),
    edge_cal: r2(edge),
    ev_raw: r2(asNum(b.ev_raw, ev)),
    roi: r2(ev),
    ev_cal: r2(ev),
    market_fit: isAvoid(j, marketKey)
      ? "avoid"
      : isPreferred(j, marketKey)
        ? "preferred"
        : (b.market_fit ?? "neutral"),
  };
}

function buildBetsFromModel(j) {
  const out = [];

  for (const market of ["result", "double_chance", "over_under", "btts"]) {
    for (const b of arr(j?.bets?.[market])) {
      const value = b.value;
      const odd = asNum(b.odd, null);
      const modelP = getModelProb(j, market, value);
      if (!odd || !modelP) continue;

      const marketKey = marketKeyFor(market, value);
      const bookP = fairBookProbFromBets(j, market, value, odd);
      if (!bookP) continue;

      const ev = ((modelP * odd) - 1) * 100;
      const edge = (modelP - bookP) * 100;

      out.push({
        market,
        market_key: marketKey,
        value,
        label: labelFor(j, market, value),
        odd,
        book_p: r3(bookP),
        model_p_raw: r3(modelP),
        model_p: r3(modelP),
        model_p_cal: r3(modelP),
        edge_raw: r2(edge),
        edge: r2(edge),
        edge_cal: r2(edge),
        ev_raw: r2(ev),
        roi: r2(ev),
        ev_cal: r2(ev),
        rating: ev >= 10 && edge >= 7 ? "A" : ev >= 6 && edge >= 5 ? "B" : ev >= 3 ? "C" : "D",
        market_fit: isAvoid(j, marketKey)
          ? "avoid"
          : isPreferred(j, marketKey)
            ? "preferred"
            : "neutral",
      });
    }
  }

  return out;
}

function getAllCandidates(j) {
  const fromExisting = arr(j.best_all_bets)
    .map(b => normalizeExistingBet(j, b))
    .filter(Boolean);

  if (fromExisting.length) return fromExisting;

  return buildBetsFromModel(j);
}

function ctx(j) {
  const mp = j.match_profile ?? {};
  const c = mp.context ?? {};

  return {
    type: mp.type ?? null,
    goal_index: asNum(mp.goal_index, null),
    low_event_score: asNum(mp.low_event_score, null),
    volatility: asNum(mp.volatility, null),
    draw_risk: asNum(mp.draw_risk, null),

    lambda_total: asNum(j?.model?.goals_lambda?.total, c.lambda_total ?? null),

    p_over25: asNum(j?.model_probs?.over_under?.["Over 2.5"], c.p_over25 ?? null),
    p_under25: asNum(j?.model_probs?.over_under?.["Under 2.5"], c.p_under25 ?? null),
    p_over35: asNum(j?.model_probs?.over_under?.["Over 3.5"], c.p_over35 ?? null),
    p_under35: asNum(j?.model_probs?.over_under?.["Under 3.5"], c.p_under35 ?? null),
    p_btts_yes: asNum(j?.model_probs?.btts?.Yes, c.p_btts_yes ?? null),
    p_btts_no: asNum(j?.model_probs?.btts?.No, c.p_btts_no ?? null),

    p_home: asNum(j?.model_probs?.result?.Home, c.p_home ?? null),
    p_draw: asNum(j?.model_probs?.result?.Draw, c.p_draw ?? null),
    p_away: asNum(j?.model_probs?.result?.Away, c.p_away ?? null),

    risk_flags: arr(j.risk_flags),

    is_world_cup: !!j.is_world_cup,
    is_world_cup_qualification: !!j.is_world_cup_qualification,
    is_knockout: !!j.is_knockout,
    market_scope: j.market_scope ?? "regular_time_90",

    model_only: isModelOnly(j),
    very_thin_data: hasVeryThinData(j),
  };
}

function brokerThreshold(j, strategy, key) {
  const value = j?.runtime_profile?.broker?.picker_thresholds?.[strategy]?.[key];
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

function thresholds(j, strategy) {
  const required = ["min_odd", "max_odd", "min_ev", "min_edge", "min_prob", "max_picks"];
  const values = Object.fromEntries(required.map(key => [key, brokerThreshold(j, strategy, key)]));
  const missing = required.filter(key => values[key] == null);
  if (missing.length) {
    throw new Error(`Missing Supabase broker.picker_thresholds.${strategy}: ${missing.join(", ")}`);
  }

  return {
    minOdd: values.min_odd,
    maxOdd: values.max_odd,
    minEv: values.min_ev,
    minEdge: values.min_edge,
    minProb: values.min_prob,
    maxPicks: values.max_picks,
  };
}

function isDoubleChanceKey(mk) {
  return mk === "double_chance_home" || mk === "double_chance_away" || mk === "double_chance_12";
}

function marketThresholds(j, strategy, mk) {
  const base = thresholds(j, strategy);
  if (!isDoubleChanceKey(mk)) return base;

  const dc = j?.runtime_profile?.broker?.picker_thresholds?.[strategy]?.double_chance;
  if (!dc || typeof dc !== "object") {
    throw new Error(`Missing Supabase broker.picker_thresholds.${strategy}.double_chance`);
  }

  const read = (key) => {
    const n = Number(dc[key]);
    if (!Number.isFinite(n)) throw new Error(`Invalid double-chance threshold: ${strategy}.${key}`);
    return n;
  };

  return {
    ...base,
    minOdd: read("min_odd"),
    maxOdd: read("max_odd"),
    minEv: read("min_ev"),
    minEdge: read("min_edge"),
    minProb: read("min_prob"),
  };
}

function strongDcAgainstFavorite(strategy, mk, odd, p, ev, edge) {
  if (mk !== "double_chance_away" && mk !== "double_chance_home") return false;

  if (strategy === "risk") {
    return odd <= 4.80 && p >= 0.30 && ev >= 6.0 && edge >= 4.0;
  }

  if (strategy === "value") {
    return odd <= 3.20 && p >= 0.34 && ev >= 8.0 && edge >= 5.0;
  }

  return odd <= 2.75 && p >= 0.44 && ev >= 2.5 && edge >= 2.0;
}

// NIEUW (v2.9): uitzondering voor ECHT elite value tegen een duidelijke markt-favoriet.
// Hoe sterker de markt-favoriet, hoe strenger de drempels.
function eliteAgainstClearFavorite(odd, p, ev, edge, favMarketP) {
  const evFloor   = favMarketP >= 0.62 ? 18 : 12;
  const edgeFloor = favMarketP >= 0.62 ? 12 : 9;
  const probFloor = favMarketP >= 0.62 ? 0.58 : 0.55;
  return odd <= 3.20 && p >= probFloor && ev >= evFloor && edge >= edgeFloor;
}

function exposureGroup(b) {
  const mk = lc(b.market_key);

  let side = "neutral_side";
  if (mk.includes("home")) side = "home_side";
  if (mk.includes("away")) side = "away_side";
  if (mk.includes("draw")) side = "draw_side";

  let event = "neutral_event";
  if (mk.includes("under") || mk.includes("btts_no")) event = "low_event";
  if (mk.includes("over") || mk.includes("btts_yes")) event = "high_event";

  return `${side}__${event}`;
}

function hardRejectReasons(j, b, strategy) {
  const c = ctx(j);

  const reasons = [];
  const mk = lc(b.market_key);
  const t = marketThresholds(j, strategy, mk);
  const isDC = isDoubleChanceKey(mk);

  const odd = asNum(b.odd, 0);
  const p = asNum(b.model_p_cal ?? b.model_p, 0);
  const ev = asNum(b.ev_cal ?? b.roi, -999);
  const edge = asNum(b.edge_cal ?? b.edge, -999);

  const homeWinOdd = getResultOdd(j, "Home");
  const awayWinOdd = getResultOdd(j, "Away");

  const heavyHomeFavorite = homeWinOdd != null && homeWinOdd <= 1.45;
  const heavyAwayFavorite = awayWinOdd != null && awayWinOdd <= 1.45;

  const modelOnlyHomeFavorite = homeWinOdd != null && homeWinOdd <= 1.65;
  const modelOnlyAwayFavorite = awayWinOdd != null && awayWinOdd <= 1.65;

  const recent = getRecentContext(j);
  const h2hBtts = h2hBttsStats(j);

  // League Settings: avoid_markets is een harde block.
  // preferred_markets blijft alleen een score/fit-signaal.
  if (isAvoid(j, mk)) reasons.push("league_avoid_market");
  else if (b.market_fit === "avoid") reasons.push("market_fit_avoid");

  if (odd < t.minOdd) reasons.push("odd_below_minimum");
  if (odd > t.maxOdd) reasons.push("odd_above_market_cap");

  if (p < t.minProb) reasons.push("prob_below_strategy_floor");
  if (ev <= 0) reasons.push("ev_not_positive");
  if (ev < t.minEv) reasons.push("ev_below_strategy_floor");
  if (edge < t.minEdge) reasons.push("edge_below_strategy_floor");

  // ---- Kanaal-succesdrempel op de RUWE modelkans (JS24, vóór de book-blend) ----
  // Safe 60% / Value 55% / Risk 45%. Hierdoor belandt bv. Over 3.5 (45%) uitsluitend in
  // RISK, en blijven de logische hoge-kans-picks (Win 63%, Over 2.5 67%) beschikbaar voor
  // Safe/Value. Dit vervangt de (dode) allow_over35-config uit de EXPANDER.
  const pRaw = asNum(b.model_p_raw ?? b.model_p_cal ?? b.model_p, 0);
  const channelSuccessFloor = strategy === "safe" ? 0.60 : strategy === "value" ? 0.55 : 0.45;
  if (pRaw < channelSuccessFloor) reasons.push("below_channel_success_floor");

  // ---- Divergentie-plafond: model vs. (de-margined) bookmaker ----
  // edge_raw = (ruwe modelkans - book-kans) in procentpunten, VOOR de 60/40 blend.
  //   > 20pp  -> altijd afwijzen (elk kanaal)
  //   > 15pp  -> alleen toegestaan in RISK (safe/value afwijzen)
  const edgeRaw = Math.abs(asNum(b.edge_raw, edge));
  const hardDivergenceCap = 20;               // extreem: overal blokkeren
  const softDivergenceCap = isRisk(strategy) ? 20 : 15; // safe/value strenger
  if (edgeRaw > hardDivergenceCap) reasons.push("model_book_divergence_extreme");
  else if (edgeRaw > softDivergenceCap) reasons.push("model_book_divergence_too_high_for_channel");

  // ---- Onafhankelijke consensus-guard voor uitslag/dubbele-kans picks ----
  // Als de 3 onafhankelijke signalen (vorm, H2H, doelpunten) de TEGENstander steunen
  // (gem. >= 60% en >= 2 van 3), wijzen we de uitslag/DC-pick af. JS8 valt dan terug op
  // de beste overige markt. poisson_distribution wordt bewust genegeerd.
  const backsHome = (mk === "result_home" || mk === "double_chance_home" || mk === "draw_no_bet_home");
  const backsAway = (mk === "result_away" || mk === "double_chance_away" || mk === "draw_no_bet_away");
  if (backsHome || backsAway) {
    const cmp = j.comparison ?? {};
    const pctAway = (o) => { const v = parseFloat(String(o?.away ?? "").replace("%", "")); return Number.isFinite(v) ? v / 100 : null; };
    const signals = [pctAway(cmp.form), pctAway(cmp.h2h), pctAway(cmp.goals)].filter((v) => v != null);
    if (signals.length >= 2) {
      const oppShares = signals.map((awayShare) => (backsHome ? awayShare : 1 - awayShare));
      const oppAvg = oppShares.reduce((a, v) => a + v, 0) / oppShares.length;
      const oppVotes = oppShares.filter((s) => s > 0.50).length;
      if (oppAvg >= 0.60 && oppVotes >= 2) {
        reasons.push("independent_form_h2h_goals_favor_opponent");
      }
    }
  }

  // Model-only guard: prediction ontbreekt/fallback.
  // Dan geen dunne underdog DC tegen duidelijke bookmaker-favoriet.
  if (c.model_only) {
    if (c.very_thin_data) {
      if (
        mk === "double_chance_away" &&
        modelOnlyHomeFavorite
      ) {
        if (odd > 2.40) reasons.push("model_only_dc_away_odd_cap");
        if (edge < 8.0) reasons.push("model_only_dc_away_edge_floor");
        if (ev < 6.0) reasons.push("model_only_dc_away_ev_floor");
      }

      if (
        mk === "double_chance_home" &&
        modelOnlyAwayFavorite
      ) {
        if (odd > 2.40) reasons.push("model_only_dc_home_odd_cap");
        if (edge < 8.0) reasons.push("model_only_dc_home_edge_floor");
        if (ev < 6.0) reasons.push("model_only_dc_home_ev_floor");
      }

      if (mk === "result_away" && modelOnlyHomeFavorite) {
        reasons.push("model_only_blocks_away_result_vs_favorite");
      }

      if (mk === "result_home" && modelOnlyAwayFavorite) {
        reasons.push("model_only_blocks_home_result_vs_favorite");
      }
    }

    if (isDC && odd >= 2.50 && edge < 7.0) {
      reasons.push("model_only_dc_edge_too_thin");
    }

    if (isDC && odd >= 2.50 && ev < 6.0) {
      reasons.push("model_only_dc_ev_too_thin");
    }
  }

  if (c.is_knockout) {
    if (
      (mk === "result_home" || mk === "result_away") &&
      c.p_draw != null &&
      c.p_draw >= 0.30
    ) {
      reasons.push("knockout_90min_draw_risk");
    }

    if (mk === "ou_over_3_5") {
      reasons.push("knockout_over35_disabled");
    }
  }

  // VALUE/RISK DC longshot guard
  if (isDC && (isValue(strategy) || isRisk(strategy))) {
    if (isValue(strategy) && odd > 3.20) {
      reasons.push("dc_value_odd_above_longshot_cap");
    }

    if (odd >= 3.00 && edge < 8.0) {
      reasons.push("dc_longshot_edge_not_strong_enough");
    }

    if (odd >= 3.00 && ev < 15.0) {
      reasons.push("dc_longshot_ev_not_strong_enough");
    }
  }

  if (!isDC && odd < 1.65) {
    if (ev < (isRisk(strategy) ? 3.5 : 5.0)) reasons.push("low_odd_ev_too_thin");
    if (edge < (isRisk(strategy) ? 3.5 : 5.0)) reasons.push("low_odd_edge_too_thin");
  }

  if (odd < 1.45) {
    reasons.push("very_low_odd_block");
  }

  if (heavyHomeFavorite) {
    const eliteDcAway = strongDcAgainstFavorite(strategy, mk, odd, p, ev, edge);

    if (mk === "double_chance_away" && !eliteDcAway) {
      reasons.push("heavy_home_favorite_blocks_x2");
    }

    if (mk === "result_away") reasons.push("heavy_home_favorite_blocks_away_result");
    if (mk === "draw_no_bet_away") reasons.push("heavy_home_favorite_blocks_away_dnb");

    if (
      awayWinOdd != null &&
      awayWinOdd >= 6 &&
      mk.includes("away") &&
      !eliteDcAway
    ) {
      reasons.push("away_longshot_against_heavy_home_favorite");
    }
  }

  if (heavyAwayFavorite) {
    const eliteDcHome = strongDcAgainstFavorite(strategy, mk, odd, p, ev, edge);

    if (mk === "double_chance_home" && !eliteDcHome) {
      reasons.push("heavy_away_favorite_blocks_1x");
    }

    if (mk === "result_home") reasons.push("heavy_away_favorite_blocks_home_result");
    if (mk === "draw_no_bet_home") reasons.push("heavy_away_favorite_blocks_home_dnb");

    if (
      homeWinOdd != null &&
      homeWinOdd >= 6 &&
      mk.includes("home") &&
      !eliteDcHome
    ) {
      reasons.push("home_longshot_against_heavy_away_favorite");
    }
  }

  // ---- CLEAR MARKET-FAVORITE GUARD (v2.9) ----
  // Blokkeer underdog DC / underdog uitslag tegen een duidelijke markt-favoriet,
  // OOK als data niet dun is en de prediction wel bestaat (Valerenga-type fix).
  // Werkt op de ontviggede markt-kans, niet op een enkele odd-drempel.
  const homeMarketP = fairBookProbFromBets(j, "result", "Home", homeWinOdd);
  const awayMarketP = fairBookProbFromBets(j, "result", "Away", awayWinOdd);

  const clearHomeFavorite = homeMarketP != null && homeMarketP >= 0.55;
  const clearAwayFavorite = awayMarketP != null && awayMarketP >= 0.55;

  if (clearHomeFavorite && !heavyHomeFavorite) {
    const elite = eliteAgainstClearFavorite(odd, p, ev, edge, homeMarketP);
    if (mk === "double_chance_away" && !elite) reasons.push("clear_home_favorite_blocks_x2");
    if (mk === "result_away" && !elite) reasons.push("clear_home_favorite_blocks_away_result");
    if (mk === "draw_no_bet_away" && !elite) reasons.push("clear_home_favorite_blocks_away_dnb");
  }

  if (clearAwayFavorite && !heavyAwayFavorite) {
    const elite = eliteAgainstClearFavorite(odd, p, ev, edge, awayMarketP);
    if (mk === "double_chance_home" && !elite) reasons.push("clear_away_favorite_blocks_1x");
    if (mk === "result_home" && !elite) reasons.push("clear_away_favorite_blocks_home_result");
    if (mk === "draw_no_bet_home" && !elite) reasons.push("clear_away_favorite_blocks_home_dnb");
  }

  // ---- Fix D: SAFE double-chance mag de zwakste uitkomst niet meepakken ----
  // 1X = Home+Draw (laat Away weg), X2 = Draw+Away (laat Home weg), 12 = Home+Away (laat Draw weg).
  // Een veilige DC hoort de twee STERKSTE uitkomsten te combineren. Pakt hij juist de zwakste
  // uitkomst mee (bijv. thuis 10%), dan is het geen veilige kant -> blokkeren voor safe/value.
  if (isDC && !isRisk(strategy)) {
    const pH = c.p_home, pD = c.p_draw, pA = c.p_away;
    if (pH != null && pD != null && pA != null) {
      const includes =
        mk === "double_chance_home" ? ["home", "draw"] :
        mk === "double_chance_away" ? ["draw", "away"] :
        ["home", "away"]; // double_chance_12
      const probByKey = { home: pH, draw: pD, away: pA };
      const minKey =
        (pH <= pD && pH <= pA) ? "home" :
        (pD <= pH && pD <= pA) ? "draw" : "away";
      const WEAK_MAX = asNum(j?.league_settings?.safe_dc_weak_max, 0.28); // configureerbaar in DB
      if (includes.includes(minKey) && probByKey[minKey] <= WEAK_MAX) {
        reasons.push("safe_dc_includes_weakest_outcome");
      }
    }
  }
  
  const marketProb = asNum(b.book_p, null);
  if (marketProb != null) {
    const divergence = p - marketProb;

    if (!isDC && divergence >= 0.28 && b.market_fit !== "preferred") {
      reasons.push("model_market_divergence_too_large");
    }

    if (!isDC && divergence >= 0.22 && (mk.includes("away") || mk.includes("home")) && (heavyHomeFavorite || heavyAwayFavorite)) {
      reasons.push("side_probability_diverges_from_market");
    }
  }

  if (mk === "btts_yes") {
    if (p < (isRisk(strategy) ? 0.52 : 0.55)) reasons.push("btts_yes_prob_too_low");
    if (edge < (isRisk(strategy) ? 4.5 : 6.0)) reasons.push("btts_yes_edge_too_low");
    if (ev < (isRisk(strategy) ? 5.0 : 7.0)) reasons.push("btts_yes_ev_too_low");

    if (c.lambda_total != null && c.lambda_total < 2.35) reasons.push("btts_yes_lambda_too_low");
    if (c.p_btts_no != null && c.p_btts_no >= 0.54) reasons.push("btts_no_model_too_live");
    if (c.p_under35 != null && c.p_under35 >= 0.76) reasons.push("under35_profile_blocks_btts_yes");
    if (c.goal_index != null && c.goal_index < 0.47) reasons.push("goal_index_too_low_for_btts_yes");

    if (recent.home.used >= 3 && recent.home.clean_sheets >= 4) reasons.push("home_clean_sheets_block_btts_yes");
    if (recent.away.used >= 3 && recent.away.scored_zero >= 4) reasons.push("away_blanks_block_btts_yes");
    if (recent.away.used >= 3 && recent.away.gf_pg != null && recent.away.gf_pg < 0.70) reasons.push("away_scoring_rate_too_low_for_btts_yes");
    if (recent.home.used >= 3 && recent.home.ga_pg != null && recent.home.ga_pg < 0.60) reasons.push("home_conceding_rate_too_low_for_btts_yes");
    if (h2hBtts.used >= 2 && h2hBtts.no_rate != null && h2hBtts.no_rate >= 0.80) reasons.push("h2h_blocks_btts_yes");
  }

  if (mk === "btts_no") {
    if (p < (isRisk(strategy) ? 0.56 : 0.62)) reasons.push("btts_no_prob_too_low");
    if (edge < (isRisk(strategy) ? 6 : 10)) reasons.push("btts_no_edge_not_elite");
    if (ev < (isRisk(strategy) ? 8 : 16)) reasons.push("btts_no_ev_not_elite");
    if (c.lambda_total != null && c.lambda_total > (isRisk(strategy) ? 2.55 : 2.35)) reasons.push("btts_no_lambda_too_high");
    if (c.p_btts_yes != null && c.p_btts_yes > (isRisk(strategy) ? 0.47 : 0.43)) reasons.push("btts_yes_too_live");
    if (c.low_event_score != null && c.low_event_score < (isRisk(strategy) ? 0.55 : 0.62)) reasons.push("btts_no_not_low_event");
  }

  if (mk === "ou_under_3_5") {
    if (p < 0.69) reasons.push("under35_prob_not_strong");
    if (ev < 3.0) reasons.push("under35_ev_too_thin");
    if (edge < 3.0) reasons.push("under35_edge_too_thin");
    if (c.lambda_total != null && c.lambda_total >= 2.90) reasons.push("under35_lambda_too_high");
    if (c.p_over25 != null && c.p_over25 >= 0.54) reasons.push("under35_over25_too_live");
    if (c.p_btts_yes != null && c.p_btts_yes >= 0.55) reasons.push("under35_btts_yes_too_live");
    if (c.goal_index != null && c.goal_index >= 0.54 && (c.low_event_score ?? 0) < 0.56) reasons.push("under35_not_low_event");
  }

  if (mk === "ou_under_2_5") {
    if (p < 0.60) reasons.push("under25_prob_not_strong");
    if (ev < 6.0) reasons.push("under25_ev_too_thin");
    if (c.lambda_total != null && c.lambda_total >= 2.35) reasons.push("under25_lambda_too_high");
    if (c.p_over25 != null && c.p_over25 >= 0.44) reasons.push("under25_over25_too_live");
  }

  if (mk === "ou_over_2_5") {
    if (p < (isRisk(strategy) ? 0.54 : 0.58)) reasons.push("over25_prob_too_low");
    if (edge < (isRisk(strategy) ? 5 : 7)) reasons.push("over25_edge_too_low");
    if (ev < (isRisk(strategy) ? 6 : 8)) reasons.push("over25_ev_too_low");
  }

  if (mk === "result_away") {
    if (p < (isRisk(strategy) ? 0.43 : 0.50)) reasons.push("away_result_prob_too_low");
    if (edge < (isRisk(strategy) ? 5 : 7)) reasons.push("away_result_edge_too_low");
    if (ev < (isRisk(strategy) ? 6 : 8)) reasons.push("away_result_ev_too_low");
  }

  if (mk === "result_home") {
    if (p < 0.50) reasons.push("home_result_prob_too_low");
    if (edge < 3.0) reasons.push("home_result_edge_too_low");
    if (ev < 3.0) reasons.push("home_result_ev_too_low");
    if (c.draw_risk != null && c.draw_risk > 0.62) reasons.push("home_result_draw_risk_too_high");
  }

  if (mk === "double_chance_home" || mk === "double_chance_12") {
    if (odd < t.minOdd) reasons.push("dc_low_odd_block");
    if (ev < t.minEv) reasons.push("dc_ev_too_thin");
    if (edge < t.minEdge) reasons.push("dc_edge_too_thin");
  }

  if (mk === "double_chance_away") {
    if (odd < t.minOdd) reasons.push("dc_away_odd_too_low");
    if (ev < t.minEv) reasons.push("dc_away_ev_too_thin");
    if (edge < t.minEdge) reasons.push("dc_away_edge_too_thin");

    if (
      c.p_home != null &&
      c.p_home >= 0.80 &&
      odd >= 4.00 &&
      !strongDcAgainstFavorite(strategy, mk, odd, p, ev, edge)
    ) {
      reasons.push("dc_away_against_strong_home_prob");
    }
  }

  if (c.risk_flags.length >= 3 && ev < 7) {
    reasons.push("risk_flags_edge_too_thin");
  }

  return uniq(reasons);
}

function scoreBet(j, b, strategy) {
  const c = ctx(j);
  const ev = asNum(b.ev_cal ?? b.roi, 0);
  const edge = asNum(b.edge_cal ?? b.edge, 0);
  const p = asNum(b.model_p_cal ?? b.model_p, 0);
  const odd = asNum(b.odd, 0);
  const mk = lc(b.market_key);

  let score = 0;
  // ---- Strategie-afhankelijke weging zodat de kanalen SPREIDEN i.p.v. samenvallen ----
  // safe  = kans-gedreven (pakt de hoogste succeskans binnen de odds),
  // value = kans + edge in balans,
  // risk  = EV/upside-gedreven (mag de longshot met dikke edge pakken).
  let wEv, wEdge, wP;
  if (strategy === "safe")       { wEv = 1.0; wEdge = 1.0; wP = 45; }
  else if (strategy === "value") { wEv = 2.0; wEdge = 1.6; wP = 28; }
  else                           { wEv = 3.0; wEdge = 1.8; wP = 12; }
  score += ev * wEv;
  score += edge * wEdge;
  score += p * wP;

  if (b.market_fit === "preferred") score += 2;
  if (b.market_fit === "avoid") score -= 50;

  if (odd < 1.65) score -= 4;
  if (odd > 2.50) score -= isRisk(strategy) ? 1 : 2;

  if (mk === "btts_no") score -= isRisk(strategy) ? 8 : 12;
  if (mk === "btts_yes") score -= 2;
  if (mk === "ou_under_3_5") score -= 6;
  if (mk === "result_home") score += 2;

  if (isDoubleChanceKey(mk)) score += strategy === "safe" ? 2 : 4;

  if (c.model_only && isDoubleChanceKey(mk) && odd >= 2.50) score -= 8;
  if (c.very_thin_data && isDoubleChanceKey(mk) && odd >= 2.50) score -= 6;

  if (c.risk_flags.length >= 3) score -= 2;

  return r2(score);
}

function roleFor(strategy) {
  if (strategy === "safe") return "recommended";
  if (strategy === "risk") return "risk_pick";
  return "value_pick";
}

function process(j) {
  const strategy = getStrategy(j);
  const all = getAllCandidates(j);

  const accepted = [];
  const rejected = [];
  const reasonsSummary = [];

  for (const raw of all) {
    const b = normalizeExistingBet(j, raw) ?? raw;
    const reasons = hardRejectReasons(j, b, strategy);

    const enriched = {
      ...b,
      strategy_key: strategy,
      exposure_group: exposureGroup(b),
      side_cluster: exposureGroup(b).split("__")[0],
      event_cluster: exposureGroup(b).split("__")[1],
      final_score: scoreBet(j, b, strategy),
      reject_reasons: reasons,
      broker_version: "js8_fixture_broker_v3_1_restored_guards",
    };

    if (reasons.length) {
      rejected.push(enriched);
      reasonsSummary.push(...reasons);
    } else {
      accepted.push(enriched);
    }
  }

  accepted.sort((a, b) => asNum(b.final_score, 0) - asNum(a.final_score, 0));

  const selected = accepted[0] ?? null;
  const picks = [];

  if (selected) {
    // ---- Fix B: stake uit fractionele Kelly (model_p + odd), begrensd per bucket ----
    const STAKE_CAP   = strategy === "risk" ? 0.35 : strategy === "value" ? 0.75 : 1; // ceiling per risicotier
    const STAKE_FLOOR = 0.1;   // minimum voor een geaccepteerde (positieve-EV) pick
    const KELLY_REF   = 0.10;  // full-Kelly-fractie die de cap haalt (10% = sterke bet)

    const _p = asNum(selected.model_p_cal, null) ?? asNum(selected.model_p, null);
    const _o = asNum(selected.odd, null);
    let stakePct = STAKE_CAP; // fallback = oude gedrag als data ontbreekt
    if (_p != null && _o != null && _o > 1) {
      const kelly = Math.max(0, (_p * _o - 1) / (_o - 1)); // full-Kelly fractie
      stakePct = STAKE_FLOOR + (STAKE_CAP - STAKE_FLOOR) * Math.min(1, kelly / KELLY_REF);
      stakePct = r2(Math.max(STAKE_FLOOR, Math.min(STAKE_CAP, stakePct)));
    }

    picks.push({
      ...selected,
      role: roleFor(strategy),
      rank: 1,
      is_primary: true,
      is_recommended: strategy === "safe",
      list: roleFor(strategy),
      stake_pct: stakePct,
      advised_stake: stakePct,
      stake_basis: "kelly_frac_v1", // diagnostiek
      publish_status: "fixture_found",
      is_fixture_found: true,
      is_telegram_published: false,
    });
  }

  const recommended_pick = strategy === "safe" ? (picks[0] ?? null) : null;
  const value_pick = strategy === "value" ? (picks[0] ?? null) : null;
  const risk_pick = strategy === "risk" ? (picks[0] ?? null) : null;
  const hasPick = !!picks.length;

  return {
    ...j,

    js8_version: "fixture_broker_v3_1_restored_guards",
    broker_version: "js8_fixture_broker_v3_1_restored_guards",

    picks,
    picks_found: picks,
    picks_rejected: rejected,

    recommended_pick,
    value_pick,
    risk_pick,
    extra_picks: [],

    has_found_pick: hasPick,
    has_publishable_pick: hasPick,
    has_bet: hasPick,

    advised_total_stake: r2(picks.reduce((s, p) => s + asNum(p.stake_pct, 0), 0)),

    info: hasPick ? "fixture pick found" : "no fixture pick passed gates",
    reason: hasPick ? null : "no fixture pick passed JS8 v3.1 restored-guards gates",

    best_all_bets: all
      .map(b => normalizeExistingBet(j, b))
      .filter(Boolean)
      .sort((a, b) => scoreBet(j, b, strategy) - scoreBet(j, a, strategy)),

    candidate_bets: accepted,

    picker_debug: {
      ...(j.picker_debug ?? {}),
      strategy_key: strategy,
      match_type: j?.match_profile?.type ?? j?.picker_debug?.match_type ?? null,
      model_only: isModelOnly(j),
      very_thin_data: hasVeryThinData(j),
      prediction_available: j.prediction_available ?? null,
      prediction_status: j.prediction_status ?? null,
      prediction_source: j.prediction_source ?? null,
      candidates_total: all.length,
      publishable_candidate_count: accepted.length,
      rejected_candidate_count: rejected.length,
      no_bet_reason_summary: uniq(reasonsSummary),
      selected_keys: picks.map(p => p.market_key),
      selected_clusters: uniq(picks.map(p => p.exposure_group)),
      notes: [
        "JS8 v3.1 restored-guards: Supabase thresholds + per-channel ranking (safe wP=45 / value 28 / risk 12), channel-success-floor (0.60/0.55/0.45), raw divergence cap (>20pp all / >15pp safe+value), independent side consensus guard",
        "JS8 v2.9: clear market-favorite guard active (>= 0.55 fair prob)",
        "Blocks underdog DC/result vs clear favorite unless value is elite",
        "Works regardless of thin data or model_only (Valerenga fix)",
        "JS8 v2.7: model-only guard active",
        "Fallback predictions no longer support DC underdogs",
        "Model-only DC at odd >= 2.50 needs stronger EV/edge",
        "Heavy favorite underdog DC blocked when data is thin",
        "Under 3.5 score penalty active",
        "League Settings avoid_markets hard-blocked as league_avoid_market",
        "EV <= 0 still blocked"
      ]
    },

    filter_debug: {
      ...(j.filter_debug ?? {}),
      no_bet_reason_summary: uniq(reasonsSummary),
    },

    broker_rejections: {
      summary: uniq(reasonsSummary),
      counts: {
        candidate_rejected: rejected.length,
        non_candidate_rejected: 0,
        total_rejected: rejected.length,
      },
      rejected_candidates: rejected.slice(0, 50),
    },

    rejected_bets: rejected,
  };
}

return items.map(item => ({ json: process(item.json ?? {}) }));