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 ?? {}) }));