• Welcome
  • Start/Sit
  • Projections
  • Edge Finder
  • Track Record
  • About
    • About FFHedge
    • Methodology
    • Media

    • reluctant criminologists

Want to know the chances behind every start-or-sit call?

FFHedge gives you fantasy projections that weigh the experts against the data, then tells you how likely each player is to boom or bust.

Every fantasy site hands you a ranked list and asks you to trust the order. FFHedge does something narrower and, I think, more useful: it gives you the chances — the probability a player clears a useful week — and it tells you when those numbers say a decision you have been agonizing over is really a coin flip.

Try it: Start/Sit →

Watch the 3-minute tour
Code
palette = ({
  accent: "#93c54b",
  accentDark: "#7aa83c",
  expert: "#b5651d",   // warm brown for the expert-driven (Model A) signal
  data:   "#3a6ea5",   // muted blue for the data-driven (Model B) signal
  mixture: "#3e3f3a",  // ink for the blended predictive
  sand: "#f8f5f0",
  bust: "#c9b8a3",
  held: "#dfe7c8",
  strong: "#b9d68a",
  leagueWinner: "#93c54b"
})

// Probability formatting with the "no false precision" rule: anything that
// would round to a flat tiny number is shown as "<1%"; high values mirror it.
fmtPct = function (p) {
  if (p == null || isNaN(p)) return "—";
  if (p < 0.01) return "<1%";
  if (p > 0.99) return ">99%";
  return (100 * p).toFixed(0) + "%";
}

// Fantasy-point formatting to one decimal.
fmtFp = function (x) {
  if (x == null || isNaN(x)) return "—";
  return x.toFixed(1);
}

// Four narrative-bin probabilities from the three exceedance probabilities.
// Inputs are P(exceed floor), P(exceed target), P(exceed ceiling).
narrativeProbs = function (pFloor, pTarget, pCeiling) {
  return {
    bust: Math.max(0, 1 - pFloor),
    held_up: Math.max(0, pFloor - pTarget),
    strong: Math.max(0, pTarget - pCeiling),
    league_winner: Math.max(0, pCeiling)
  };
}

// Map an ECR rank to its tier label (matches the export's ecr_tier bins).
ecrTier = function (ecr) {
  if (ecr == null || isNaN(ecr)) return null;
  if (ecr <= 5) return "1-5";
  if (ecr <= 12) return "6-12";
  if (ecr <= 24) return "13-24";
  if (ecr <= 48) return "25-48";
  if (ecr <= 96) return "49-96";
  return "97+";
}

// Position-prefixed ECR label. In Flex mode WR and RB ranks collide (both
// have a "#6"), so we prefix with the player's position (RB6 / WR6); in
// single-position mode the bare rank is unambiguous. The middle argument is a
// rank (the dense posRank from addPosRank below), not the raw continuous ECR.
ecrDisp = function (pos, ecr, isFlex) {
  if (ecr == null || isNaN(ecr)) return "—";
  const n = Math.round(ecr);
  return isFlex ? `${pos ?? "WR"}${n}` : `${n}`;
}

// Dense positional ECR rank (1..N within each position for a week's active
// pool, gapless). ecr_rank is the continuous FantasyPros average rank, so
// rounding it for display yields duplicate/skipped integers; dense-ranking
// recovers a clean ordinal rank. Mutates rows (adds `posRank`), returns rows.
addPosRank = function (rows) {
  const byPos = d3.group(rows, d => d.position ?? "WR");
  for (const [, ps] of byPos) {
    ps.slice()
      .sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999))
      .forEach((p, i) => { p.posRank = i + 1; });
  }
  return rows;
}

// Linear mix of the Expert and Data marginals at lean w (w = weight on Expert).
// Exact for exceedance probabilities and the mean; use for all Blend numbers.
// (Percentiles are NOT linear — never synthesize blend percentiles with this.)
blendField = (em, dm, w, field) => {
  const e = em?.[field], d = dm?.[field];
  if (e == null || d == null) return e ?? d ?? null;
  return w * e + (1 - w) * d;
}

// Reference: the data-optimal stacked weight per position (what we used to
// deploy before Stage 1's 0.50 hedge), for the slider caption. Flex omitted
// (mixed pool, no single stacked weight).
stackedLean = ({ WR: 0.378, RB: 0.077, TE: 0.286, QB: 0.085 })

// Human-readable archetype labels for badges (WR archetype set).
archetypeLabel = ({
  fill_in_situation: "fill-in situation",
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  rookie_or_low_sample: "rookie / low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})

// RB archetype set. The RB build ships a different seven flags: fill_in_rb,
// is_rookie, and low_sample come straight from the feature table; the other four
// are carry-share analogs of the WR snap-share archetypes (see methodology).
rbFlagKeys = ["fill_in_rb","is_rookie","low_sample","late_season_expansion",
              "recent_role_change","stable_veteran","star_returning","dual_threat_rb"]
rbArchetypeLabel = ({
  fill_in_rb: "fill-in (handcuff)",
  is_rookie: "rookie",
  low_sample: "low sample",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  stable_veteran: "stable veteran",
  star_returning: "star returning",
  dual_threat_rb: "dual-threat back"
})
rbCompactLabel = ({
  fill_in_rb: "fill-in", is_rookie: "rookie", low_sample: "low-smp",
  late_season_expansion: "late-exp", recent_role_change: "role-chg",
  stable_veteran: "stable", star_returning: "star-ret",
  dual_threat_rb: "dual-threat"
})

// TE archetype set (7 flags; target-share based — see te_archetype_flags.md).
teFlagKeys = ["emerging_player_elevation","late_season_expansion","recent_role_change",
              "is_rookie","low_sample","stable_veteran","star_returning"]
teArchetypeLabel = ({
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  is_rookie: "rookie",
  low_sample: "low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})
teCompactLabel = ({
  emerging_player_elevation:"emerging", late_season_expansion:"late-exp",
  recent_role_change:"role-chg", is_rookie:"rookie", low_sample:"low-smp",
  stable_veteran:"stable", star_returning:"star-ret"
})

// QB archetype set (8 flags; usage + ECR-tier based — see qb_archetype_flags.md).
// stable_starter is RANK-based (trailing-4wk mean ECR in the top 12), distinct from
// the usage-based WR/TE/RB stable_veteran; rushing_qb is the QB-specific dual-threat
// flag (a stable type from prior-season carries/game — see methodology).
qbFlagKeys = ["stable_starter","rushing_qb","new_starter","emerging_elevation",
              "late_season_expansion","star_returning","is_rookie","low_sample"]
qbArchetypeLabel = ({
  stable_starter: "stable starter",
  rushing_qb: "dual-threat QB",
  new_starter: "new starter",
  emerging_elevation: "emerging role",
  late_season_expansion: "late-season expansion",
  star_returning: "star returning",
  is_rookie: "rookie",
  low_sample: "low sample"
})
qbCompactLabel = ({
  stable_starter:"stable", rushing_qb:"dual-threat", new_starter:"new-str",
  emerging_elevation:"emerging", late_season_expansion:"late-exp",
  star_returning:"star-ret", is_rookie:"rookie", low_sample:"low-smp"
})

// Slot thresholds by position. Decoupled from the locked-config files so the
// Flex position's combined slots (WR1/RB1, WR2/RB2) resolve to a single set of
// thresholds — the WR and RB tiers share identical floor/target/ceiling values.
positionThresholds = ({
  WR: { WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  RB: { RB1: { floor: 12, target: 16, ceiling: 20 },
        RB2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  TE: { TE1: { floor: 5, target: 8, ceiling: 12 },
        WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  QB: { QB1: { floor: 15, target: 20, ceiling: 25 } },
  Flex: { "WR1/RB1": { floor: 12, target: 16, ceiling: 20 },
          "WR2/RB2": { floor: 10, target: 12, ceiling: 15 },
          Flex: { floor: 6, target: 10, ceiling: 15 } }
})

// Slot dropdown options per position. WR/RB keep their own tiers; Flex mixes the
// two pools with combined tier labels (no position-specific tier filtering).
slotOptionsFor = function (position) {
  if (position === "RB") return ["RB1", "RB2", "Flex"];
  if (position === "TE") return ["TE1", "Flex"];
  if (position === "QB") return ["QB1"];
  if (position === "Flex") return ["WR1/RB1", "WR2/RB2", "Flex"];
  return ["WR1", "WR2", "Flex"];
}

// Map an exceedance-probability slot to the per-row column name. RB and WR rows
// carry the same slot column names as their own position; the Flex combined
// slots read the underlying-position column on each row (WR1/RB1 -> WR1 on a WR
// row, RB1 on an RB row).
slotColFor = function (slot, rowPosition) {
  if (slot === "WR1/RB1") return rowPosition === "RB" ? "RB1" : "WR1";
  if (slot === "WR2/RB2") return rowPosition === "RB" ? "RB2" : "WR2";
  return slot;
}

// Per-row archetype keys and compact/full labels, branching on the row's
// position. Used in Flex mode where WR and RB rows are interleaved.
flagKeysForRow = (rowPosition) => rowPosition === "RB" ? rbFlagKeys : rowPosition === "TE" ? teFlagKeys : rowPosition === "QB" ? qbFlagKeys : FLAG_KEYS_WR;
compactLabelForRow = (rowPosition) => rowPosition === "RB" ? rbCompactLabel : rowPosition === "TE" ? teCompactLabel : rowPosition === "QB" ? qbCompactLabel : compactLabelWR;
fullLabelForRow = (rowPosition) => rowPosition === "RB" ? rbArchetypeLabel : rowPosition === "TE" ? teArchetypeLabel : rowPosition === "QB" ? qbArchetypeLabel : archetypeLabel;

// WR flag keys / compact labels live here too so the per-row resolvers above
// work on every page without each page having to define the WR set first.
FLAG_KEYS_WR = ["fill_in_situation","emerging_player_elevation","late_season_expansion",
                "recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]
compactLabelWR = ({
  fill_in_situation:"fill-in", emerging_player_elevation:"emerging",
  late_season_expansion:"late-exp", recent_role_change:"role-chg",
  rookie_or_low_sample:"rookie/ls", stable_veteran:"stable", star_returning:"star-ret"
})

// Biggest within-tier predictive surprise: among players of `position` whose ECR
// rank falls in [lo, hi] (one tier), the pair the experts ranked furthest apart
// that the deployed blend (cross_blend) still calls a coin flip — within `margin`
// on the floor, target, AND ceiling of `slot` (the roster slot you'd start that
// tier in). Ranks the position pool by true ECR each week; both players must sit in
// the tier. Falls back to the closest in-tier pair if no coin flip exists; null if
// the tier is empty. Returns { week, a, b, gapRanks, slot } with a/b =
// { id, name, team, position, ecr, rank, blend }.
mostSurprisingCoinflip = function (rows, weeks, position, lo, hi, slot, margin = 0.03) {
  const col = t => `p_${slot}_${t}`;
  let best = null, closest = null;
  for (const wk of weeks) {
    const wkRows = rows.filter(d => Number(d.week) === Number(wk) && (d.position ?? "WR") === position);
    const byPlayer = d3.group(wkRows, d => d.player_id);
    const pool = [];
    for (const [id, rs] of byPlayer) {
      const blend = rs.find(r => r.predictive === "cross_blend");
      if (!blend || rs[0].ecr_rank == null) continue;
      pool.push({ id, name: rs[0].player_display_name, team: rs[0].team,
                  position: rs[0].position ?? "WR", ecr: rs[0].ecr_rank, blend });
    }
    pool.sort((a, b) => d3.ascending(a.ecr, b.ecr));
    pool.forEach((p, i) => p.rank = i + 1);
    const tier = pool.filter(p => p.rank >= lo && p.rank <= hi);
    for (let i = 0; i < tier.length; i++) {
      for (let j = i + 1; j < tier.length; j++) {
        const a = tier[i], b = tier[j];
        const dF = Math.abs(a.blend[col("floor")] - b.blend[col("floor")]);
        const dT = Math.abs(a.blend[col("target")] - b.blend[col("target")]);
        const dC = Math.abs(a.blend[col("ceiling")] - b.blend[col("ceiling")]);
        if ([dF, dT, dC].some(v => v == null || isNaN(v))) continue;
        const gap = Math.abs(a.rank - b.rank);
        const comb = dF + dT + dC;
        if (dF < margin && dT < margin && dC < margin) {
          if (!best || gap > best.gapRanks) best = { week: wk, a, b, gapRanks: gap, slot };
        }
        if (!closest || comb < closest._comb) closest = { week: wk, a, b, gapRanks: gap, slot, _comb: comb };
      }
    }
  }
  return best ?? closest;
}

// Multi-select "filter by situation" chip row. Returns a viewof-compatible
// element whose .value is the array of selected archetype keys for `position`
// (empty when `hidden`, e.g. the season/in-dev views). OR semantics: a table row
// matches if it carries any selected flag. Used by Projections and Edge Finder.
archFilterChips = function (position, hidden) {
  if (hidden) { const e = html`<div></div>`; e.value = []; return e; }
  const keys = flagKeysForRow(position);
  const labels = fullLabelForRow(position);
  const sel = new Set();
  const box = html`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0.3rem 0 0.5rem;"></div>`;
  box.appendChild(html`<span style="font-size:0.8rem;color:var(--rc-muted);margin-right:2px;">Filter by situation:</span>`);
  const paint = (chip, on) => { chip.style.cssText = `cursor:pointer;font-size:0.75rem;padding:2px 9px;border-radius:11px;border:1px solid var(--rc-sand-panel);background:${on ? "rgba(147,197,75,0.22)" : "var(--rc-sand)"};color:${on ? "#3e3f3a" : "#6b6b6b"};font-weight:${on ? 600 : 400};`; };
  for (const k of keys) {
    const chip = html`<button type="button">${labels[k]}</button>`;
    paint(chip, false);
    chip.onclick = () => { sel.has(k) ? sel.delete(k) : sel.add(k); paint(chip, sel.has(k)); box.value = Array.from(sel); box.dispatchEvent(new CustomEvent("input", { bubbles: true })); };
    box.appendChild(chip);
  }
  box.value = [];
  return box;
}

This week’s biggest coin flip

Among the startable pool — the top 36 WR or RB, the top 18 TE, the top 12 QB by consensus rank — these are the two players the experts rank furthest apart that the model still calls a coin flip: nearly the same chances at every line despite very different ranks. Pick a position and week below; to put any two players head to head yourself, go to Start/Sit →.

Code
db = DuckDBClient.of({
  wrPred: FileAttachment("data/predictives.parquet"),
  rbPred: FileAttachment("data/rb_predictives.parquet"),
  tePred: FileAttachment("data/te_predictives.parquet"),
  qbPred: FileAttachment("data/qb_predictives.parquet")
})
wrPredictives = db.query(`SELECT * FROM wrPred`)
rbPredictives = db.query(`SELECT * FROM rbPred`)
tePredictives = db.query(`SELECT * FROM tePred`)
qbPredictives = db.query(`SELECT * FROM qbPred`)
Code
viewof featPos = Inputs.radio(["QB", "WR", "RB", "TE"], { value: "WR", label: "Position:" })
Code
featRows = featPos === "RB" ? rbPredictives : featPos === "TE" ? tePredictives : featPos === "QB" ? qbPredictives : wrPredictives
Code
// Default to the latest week in the data (week 17 for the 2025 archive; auto-advances live 2026).
viewof featWeek = {
  const weeks = Array.from(new Set(featRows.map(d => Number(d.week)))).sort((a, b) => b - a);
  return Inputs.select(weeks, { value: weeks[0], label: "Week (2025):" });
}
Code
// ECR bands per position (startable pool); the measuring slot is Flex for WR/RB, TE1 for TE, QB1 for QB.
featured = {
  const band = {
    WR: { lo: 1, hi: 36, slot: "Flex" },
    RB: { lo: 1, hi: 36, slot: "Flex" },
    TE: { lo: 1, hi: 18, slot: "TE1" },
    QB: { lo: 1, hi: 12, slot: "QB1" }
  }[featPos];
  return mostSurprisingCoinflip(featRows, [featWeek], featPos, band.lo, band.hi, band.slot);
}
Code
{
  if (!featured) return html`<div style="color:var(--rc-muted);font-style:italic;margin:1rem 0;">No qualifying coin flip in the top ${featPos === "TE" ? 18 : featPos === "QB" ? 12 : 36} this week.</div>`;
  let a = featured.a, b = featured.b;
  if (a.rank > b.rank) { const t = a; a = b; b = t; }
  const slot = featured.slot;
  const hi = featPos === "TE" ? 18 : featPos === "QB" ? 12 : 36;
  const th = positionThresholds[featPos][slot];
  const colA = palette.accent, colB = palette.data;
  const pget = (pl, t) => pl.blend[`p_${slot}_${t}`];
  const bar = (v, col) => html`<div style="flex:1;background:var(--rc-sand-panel);border-radius:3px;height:13px;">
      <div style="width:${(100*(v??0)).toFixed(1)}%;background:${col};height:100%;border-radius:3px;"></div></div>`;
  const lineRow = (label, t) => html`<div style="margin-bottom:0.45rem;">
    <div style="font-size:0.8rem;margin-bottom:2px;"><strong>${label} — ${th[t]} fp</strong></div>
    <div style="display:flex;align-items:center;gap:8px;margin:1px 0;">
      <span style="width:200px;font-size:0.76rem;font-weight:600;color:${colA};">${a.name} <span style="color:var(--rc-muted);font-weight:400;">${featPos} #${a.rank}</span></span>${bar(pget(a,t),colA)}<span style="width:36px;text-align:right;font-size:0.76rem;">${fmtPct(pget(a,t))}</span></div>
    <div style="display:flex;align-items:center;gap:8px;margin:1px 0;">
      <span style="width:200px;font-size:0.76rem;font-weight:600;color:${colB};">${b.name} <span style="color:var(--rc-muted);font-weight:400;">${featPos} #${b.rank}</span></span>${bar(pget(b,t),colB)}<span style="width:36px;text-align:right;font-size:0.76rem;">${fmtPct(pget(b,t))}</span></div>
  </div>`;
  const slotLabel = slot === "TE1" ? "TE start line (5 / 8 / 12 fp)" : slot === "QB1" ? "QB start line (15 / 20 / 25 fp)" : "flex start line (6 / 10 / 15 fp)";
  return html`<div style="margin:1.2rem 0;border:1px solid var(--rc-sand-panel);border-radius:8px;padding:1rem 1.2rem;background:rgba(147,197,75,0.06);">
    <div style="font-size:0.74rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rc-muted);margin-bottom:0.25rem;">Week ${featured.week}'s biggest coin-flip surprise · top ${hi} ${featPos} (#ECR)</div>
    <div style="font-size:1.05rem;font-weight:700;line-height:1.4;margin-bottom:0.6rem;">The experts rank ${a.name} and ${b.name} ${featured.gapRanks} spots apart, yet the model gives them nearly the same chances at every line.</div>
    ${["floor","target","ceiling"].map((t,i) => lineRow(["Floor","Target","Ceiling"][i], t))}
    <div style="font-size:0.9rem;margin-top:0.5rem;color:var(--rc-ink);">A genuine coin flip; every line lands within the model's calibration margin. <a href="compare.html">Compare any two players, any week →</a></div>
    <div style="font-size:0.72rem;color:var(--rc-muted);margin-top:0.5rem;">Chances shown at the ${slotLabel}.</div>
  </div>`;
}

Slide the hedge: where the experts and the data pull apart

This week’s single biggest expert–data disagreement in the same pool. By default the two models are weighed evenly; move the slider to lean toward the experts or the data and watch the player’s chances at each line move between the two views. Customize the blend for any player on Projections → · see the week’s biggest gaps on Edge Finder →.

Code
disagree = {
  const band = { WR: {lo:1,hi:36,slot:"Flex"}, RB: {lo:1,hi:36,slot:"Flex"}, TE: {lo:1,hi:18,slot:"TE1"}, QB: {lo:1,hi:12,slot:"QB1"} }[featPos];
  const wkRows = featRows.filter(d => Number(d.week) === Number(featWeek) && (d.position ?? "WR") === featPos);
  const byPlayer = d3.group(wkRows, d => d.player_id);
  const pool = [];
  for (const [id, rs] of byPlayer) {
    const em = rs.find(r => r.predictive === "expert_marginal");
    const dm = rs.find(r => r.predictive === "data_marginal");
    if (!em || !dm || em.mean == null || dm.mean == null || rs[0].ecr_rank == null) continue;
    pool.push({ id, name: rs[0].player_display_name, team: rs[0].team, ecr: rs[0].ecr_rank, em, dm, gap: dm.mean - em.mean });
  }
  pool.sort((a, b) => d3.ascending(a.ecr, b.ecr));
  pool.forEach((p, i) => p.rank = i + 1);
  const inBand = pool.filter(p => p.rank >= band.lo && p.rank <= band.hi);
  inBand.sort((a, b) => d3.descending(Math.abs(a.gap), Math.abs(b.gap)));
  return inBand.length ? { ...inBand[0], slot: band.slot } : null;
}
Code
viewof lean = {
  const rng = html`<input type=range min=0 max=1 step=0.01 value=0.5 style="width:240px;accent-color:#3e3f3a;">`;
  const val = html`<span style="font-size:0.78rem;font-weight:600;min-width:34px;text-align:right;">0.50</span>`;
  const box = html`<div style="display:flex;align-items:center;gap:10px;margin:0.5rem 0;">
    <span style="font-size:0.82rem;font-weight:700;color:${palette.data};">Data</span>
    ${rng}
    <span style="font-size:0.82rem;font-weight:700;color:${palette.expert};">Expert</span>
    ${val}
  </div>`;
  box.value = +rng.value;
  rng.addEventListener("input", () => {
    box.value = +rng.value;
    val.textContent = (+rng.value).toFixed(2);
    box.dispatchEvent(new CustomEvent("input", { bubbles: true }));
  });
  return box;
}
Code
{
  if (!disagree) return html`<div style="color:var(--rc-muted);font-style:italic;margin:1rem 0;">No qualifying disagreement this week.</div>`;
  const d = disagree, slot = d.slot, th = positionThresholds[featPos][slot];
  const pAt = (t, w) => blendField(d.em, d.dm, w, `p_${slot}_${t}`);
  const dir = d.gap >= 0
    ? html`<span style="color:${palette.data};font-weight:600;">Data is higher by ${fmtFp(d.gap)} fp</span>`
    : html`<span style="color:${palette.expert};font-weight:600;">Expert is higher by ${fmtFp(-d.gap)} fp</span>`;
  const pct = v => `${(100*(v??0)).toFixed(1)}%`;
  const bar = (t) => {
    const vNow = pAt(t, lean), vE = pAt(t, 1), vD = pAt(t, 0);
    return html`<div style="margin-bottom:0.5rem;">
      <div style="font-size:0.8rem;margin-bottom:2px;"><strong>${t[0].toUpperCase()+t.slice(1)} — ${th[t]} fp</strong></div>
      <div style="display:flex;align-items:center;gap:8px;">
        <div style="position:relative;flex:1;background:var(--rc-sand-panel);border-radius:3px;height:15px;">
          <div style="width:${pct(vNow)};background:${palette.mixture};height:100%;border-radius:3px;"></div>
          <div title="Data view" style="position:absolute;top:-2px;left:${pct(vD)};width:2px;height:19px;background:${palette.data};"></div>
          <div title="Expert view" style="position:absolute;top:-2px;left:${pct(vE)};width:2px;height:19px;background:${palette.expert};"></div>
        </div>
        <span style="width:42px;text-align:right;font-size:0.78rem;font-weight:600;">${fmtPct(vNow)}</span>
      </div>
    </div>`;
  };
  return html`<div style="margin:1rem 0 1.4rem;border:1px solid var(--rc-sand-panel);border-radius:8px;padding:1rem 1.2rem;background:var(--rc-sand);">
    <div style="font-size:1.0rem;font-weight:700;margin-bottom:0.15rem;">${d.name} <span style="color:var(--rc-muted);font-weight:400;font-size:0.85rem;">${featPos} #${d.rank} · ${d.team ?? "—"}</span></div>
    <div style="font-size:0.85rem;margin-bottom:0.7rem;">${dir} this week. The ink bar is the blended chance at your current lean; it slides between the <span style="color:${palette.data};font-weight:600;">data view</span> and the <span style="color:${palette.expert};font-weight:600;">expert view</span> marks as you move the slider.</div>
    ${["floor","target","ceiling"].map(t => bar(t))}
    <div style="font-size:0.72rem;color:var(--rc-muted);margin-top:0.4rem;">Default lean is 0.50 — an even hedge.</div>
  </div>`;
}

Where to start

Start/Sit → Projections →

Start/Sit puts two players head to head and shows the chances each clears a useful week — the core of FFHedge. Projections lists every active QB, WR, RB, and TE week by week, with the full distribution behind each number.

About FFHedge

Two structurally different models sit behind every projection; one is anchored to expert consensus, the other is built from player usage and game environment. They are combined and calibrated against past seasons of results, so what you get is a probability rather than a single number: the chance a player clears a useful floor, a strong target, or an elite ceiling in the roster slot you would actually start them in.

By default the two models are weighed evenly, a deliberate hedge rather than a tuned number. Where they disagree is where a start-or-sit call is genuinely hard, and the site is built to show you that disagreement and let you decide how to weigh the experts against the data.

When the model calls a decision a coin flip, it is not shrugging; history says these calls are about 50/50 either way, so you can stop worrying and follow your instincts, or just flip a coin.

Dig deeper: About → · How it’s built → · Track record →.

The data signal — player usage, snaps, injuries, schedules, and Vegas lines — comes from the nflverse project; the expert signal is the FantasyPros consensus ranking. Built with Claude Code.

Note

You are looking at the 2025 validation archive, the held-out season the model was tested on out of sample. Live 2026 projections arrive with the schedule.

FFHedge · 2025 season validation archive · a reluctant criminologists project.