1// v3.4.70 JS cache bust 2 3 4/* Iceto OddsTools - client UI 5 * Works without template modifications by injecting a FAB + loading this file. 6 */ 7(function(){ 8 const W = window; 9 const CFG = (W.IcetoOddsTools && W.IcetoOddsTools.config) || {}; 10 if (!CFG || !CFG.enabled) return; 11 12 // Mobile polish: track the usable viewport height so the popup panel can 13 // "snap" to it (address bar + keyboard can change the effective height). 14 let icetoViewportVarBound = false; 15 function icetoUpdateViewportVar(){ 16 try { 17 const h = (W.visualViewport && W.visualViewport.height) ? W.visualViewport.height : W.innerHeight; 18 document.documentElement.style.setProperty('--iceto-ot-viewportH', Math.round(h) + 'px'); 19 } catch (e) {} 20 } 21 function icetoBindViewportVar(){ 22 if (icetoViewportVarBound) return; 23 icetoViewportVarBound = true; 24 icetoUpdateViewportVar(); 25 if (W.visualViewport && W.visualViewport.addEventListener){ 26 W.visualViewport.addEventListener('resize', icetoUpdateViewportVar, {passive:true}); 27 W.visualViewport.addEventListener('scroll', icetoUpdateViewportVar, {passive:true}); 28 } 29 W.addEventListener('resize', icetoUpdateViewportVar, {passive:true}); 30 W.addEventListener('orientationchange', icetoUpdateViewportVar, {passive:true}); 31 } 32 33 // Mark current color scheme for the widget (used by CSS for dark mode). 34 // This avoids relying on theme-specific classes and keeps light mode untouched. 35 function applySchemeMarker(){ 36 try { 37 // XenForo sometimes uses a light wrapper background even in dark styles. 38 // We therefore prioritize the real page canvas (html/body) and also consider text color. 39 const elHtml = document.documentElement; 40 const elBody = document.body; 41 42 // If XenForo exposes a scheme attribute/class, trust it. 43 const attrCandidates = ['data-color-scheme','data-xf-color-scheme','data-theme','data-scheme']; 44 for (const a of attrCandidates){ 45 const v = elHtml.getAttribute(a) || elBody.getAttribute(a); 46 if (v && /dark/i.test(v)){ 47 elHtml.setAttribute('data-iceto-ot-scheme','dark'); 48 return; 49 } 50 if (v && /light/i.test(v)){ 51 elHtml.setAttribute('data-iceto-ot-scheme','light'); 52 return; 53 } 54 } 55 const classStr = (elHtml.className || '') + ' ' + (elBody.className || ''); 56 if (/\bdark\b/i.test(classStr) && !/\blight\b/i.test(classStr)){ 57 elHtml.setAttribute('data-iceto-ot-scheme','dark'); 58 return; 59 } 60 61 const parseRGBA = (bg) => { 62 const mm = (bg || '').match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d\.]+))?\)/i); 63 if (!mm) return null; 64 const a = (mm[4] === undefined) ? 1 : parseFloat(mm[4]); 65 return { r: parseInt(mm[1],10), g: parseInt(mm[2],10), b: parseInt(mm[3],10), a }; 66 }; 67 68 const lumOf = (rgb) => (0.2126*rgb.r + 0.7152*rgb.g + 0.0722*rgb.b) / 255; 69 70 // Background sampling - prefer html/body; otherwise fall back to XF wrappers. 71 const candidates = [ 72 document.querySelector('.block-container'), 73 document.querySelector('.p-body-inner'), 74 document.querySelector('.p-body'), 75 document.querySelector('.p-pageWrapper'), 76 elBody, 77 elHtml 78 ].filter(Boolean); 79 80 let bgLum = null; 81 82 for (const el of candidates){ 83 const st = getComputedStyle(el); 84 const bg = parseRGBA(st.backgroundColor); 85 if (!bg || bg.a <= 0.02) continue; 86 bgLum = lumOf(bg); 87 // if html/body provides a solid bg, stop there 88 if (el === elHtml || el === elBody) break; 89 } 90 91 // Text sampling - dark styles generally have light text. 92 let textLum = null; 93 try { 94 const st = getComputedStyle(document.querySelector('.p-body') || elBody || elHtml); 95 const c = parseRGBA(st.color); 96 if (c) textLum = lumOf(c); 97 } catch(e){} 98 99 // Decide scheme: 100 // - If background is known, trust it. 101 // - Otherwise infer from text color (light text -> dark scheme). 102 let scheme = 'light'; 103 if (bgLum !== null){ 104 scheme = (bgLum < 0.45) ? 'dark' : 'light'; 105 } else if (textLum !== null){ 106 scheme = (textLum > 0.65) ? 'dark' : 'light'; 107 } 108 109 elHtml.setAttribute('data-iceto-ot-scheme', scheme); 110 } catch(e){} 111 } 112 113 applySchemeMarker(); 114 // Keep scheme marker in sync when the user switches styles without a full reload. 115 // (Some styles toggle via attribute/class changes or stylesheet swaps.) 116 function scheduleSchemeUpdate(){ 117 if (scheduleSchemeUpdate._t) return; 118 scheduleSchemeUpdate._t = W.setTimeout(function(){ 119 scheduleSchemeUpdate._t = 0; 120 applySchemeMarker(); 121 }, 60); 122 } 123 124 // Watch for theme/style changes on <html>/<body>. 125 try { 126 const schemeAttrs = ['class','style','data-color-scheme','data-xf-color-scheme','data-theme','data-scheme']; 127 const mo = new MutationObserver(scheduleSchemeUpdate); 128 mo.observe(document.documentElement, { attributes: true, attributeFilter: schemeAttrs }); 129 if (document.body){ 130 mo.observe(document.body, { attributes: true, attributeFilter: schemeAttrs }); 131 } 132 } catch(e){} 133 134 // Watch for stylesheet changes (some style switchers swap CSS files). 135 try { 136 const head = document.head; 137 if (head){ 138 const moHead = new MutationObserver(function(muts){ 139 for (const m of muts){ 140 if (!m.addedNodes) continue; 141 for (const n of m.addedNodes){ 142 if (!n || !n.tagName) continue; 143 const t = n.tagName.toUpperCase(); 144 if (t === 'LINK' || t === 'STYLE'){ 145 scheduleSchemeUpdate(); 146 return; 147 } 148 } 149 } 150 }); 151 moHead.observe(head, { childList: true, subtree: true }); 152 } 153 } catch(e){} 154 155 // OS-level scheme changes (rare, but free). 156 try { 157 const mq = W.matchMedia && W.matchMedia('(prefers-color-scheme: dark)'); 158 if (mq && mq.addEventListener){ 159 mq.addEventListener('change', scheduleSchemeUpdate); 160 } else if (mq && mq.addListener){ 161 mq.addListener(scheduleSchemeUpdate); 162 } 163 } catch(e){} 164 165 // XenForo re-init hooks (when available). 166 try { 167 document.addEventListener('xf:load', scheduleSchemeUpdate); 168 document.addEventListener('xf:reinit', scheduleSchemeUpdate); 169 document.addEventListener('visibilitychange', scheduleSchemeUpdate); 170 W.addEventListener('focus', scheduleSchemeUpdate); 171 } catch(e){} 172 173 const qs = (sel, root=document)=>root.querySelector(sel); 174 const qsa = (sel, root=document)=>Array.from(root.querySelectorAll(sel)); 175 176 const TAB_DEFS = [ 177 { id:'odds', label:'Odds', render: renderOdds }, 178 { id:'margin', label:'Margin', render: renderMargin }, 179 { id:'arb', label:'Arb', render: renderArb }, 180 { id:'parlay', label:'Parlay', render: renderParlay }, 181 { id:'kelly', label:'Kelly', render: renderKelly }, 182 { id:'value', label:'Value', render: renderValue }, 183 { id:'hedge', label:'Hedge', render: renderHedge }, 184 { id:'dutch', label:'Dutch', render: renderDutch }, 185 { id:'lay', label:'Lay', render: renderLay }, 186 { id:'freebet',label:'Free bet',render: renderFreebet }, 187 ]; 188 189 const TAB_MAP = Object.fromEntries(TAB_DEFS.map(t=>[t.id,t])); 190 191 // Track active tab for copy-line (outer-scope so the handler can read it). 192 let ACTIVE_TAB_ID = null; 193 194 function clamp(n, a, b){ return Math.min(b, Math.max(a, n)); } 195 196 // --- Lightweight undo support for paste-normalized fields 197 // We sanitize pasted text (and sometimes normalize after paste), which can break 198 // native browser undo stacks. This keeps Ctrl/Cmd+Z working for paste actions. 199 const _undoStacks = new WeakMap(); 200 function pushUndo(el){ 201 if (!el) return; 202 let st = _undoStacks.get(el); 203 if (!st){ 204 st = []; 205 _undoStacks.set(el, st); 206 } 207 const v = String(el.value ?? ''); 208 if (!st.length || st[st.length - 1] !== v){ 209 st.push(v); 210 if (st.length > 40) st.shift(); 211 } 212 } 213 function popUndo(el){ 214 const st = _undoStacks.get(el); 215 if (st && st.length) return st.pop(); 216 return null; 217 } 218 219// Normalize locale formatted numbers: 220// - "1,91" -> "1.91" 221// - "1.000,50" -> "1000.50" 222// - "1,000.50" -> "1000.50" 223// Also strips spaces and non-number junk. 224function normalizeLocaleNumberString(raw){ 225 let s = String(raw ?? '').trim(); 226 if (!s) return ''; 227 228 // keep fractions as-is 229 if (/^\d+\s*\/\s*\d+$/.test(s)){ 230 return s.replace(/\s+/g,''); 231 } 232 233 // remove spaces 234 s = s.replace(/\s+/g,''); 235 236 // capture sign 237 let sign = ''; 238 if (s[0] === '+' || s[0] === '-'){ 239 sign = s[0]; 240 s = s.slice(1); 241 } 242 243 // keep only digits and separators 244 s = s.replace(/[^0-9\.,]/g,''); 245 if (!s) return sign ? sign : ''; 246 247 const hasDot = s.indexOf('.') !== -1; 248 const hasComma = s.indexOf(',') !== -1; 249 250 const isDigits = (x)=>/^\d+$/.test(x); 251 const looksGrouped = (arr)=>arr.length > 1 && isDigits(arr[0]) && arr.slice(1).every(p=>isDigits(p) && p.length === 3); 252 253 if (hasDot && hasComma){ 254 const lastDot = s.lastIndexOf('.'); 255 const lastComma = s.lastIndexOf(','); 256 if (lastDot > lastComma){
257 // dot decimal, comma thousands 258 s = s.replace(/,/g,''); 259 } else { 260 // comma decimal, dot thousands 261 s = s.replace(/\./g,''); 262 s = s.replace(/,/g,'.'); 263 } 264 } else if (hasComma){ 265 const arr = s.split(','); 266 if (looksGrouped(arr)){ 267 // thousands grouping 268 s = arr.join(''); 269 } else { 270 // treat last comma as decimal separator 271 const last = s.lastIndexOf(','); 272 s = s.slice(0,last).replace(/,/g,'') + '.' + s.slice(last+1); 273 } 274 } else if (hasDot){ 275 const arr = s.split('.'); 276 if (looksGrouped(arr)){ 277 s = arr.join(''); 278 } else if (arr.length > 2){ 279 // keep last dot as decimal, others as thousands 280 const last = s.lastIndexOf('.'); 281 s = s.slice(0,last).replace(/\./g,'') + '.' + s.slice(last+1); 282 } 283 } 284 285 // collapse multiple dots if any remain 286 if ((s.match(/\./g) || []).length > 1){ 287 const last = s.lastIndexOf('.'); 288 s = s.slice(0,last).replace(/\./g,'') + '.' + s.slice(last+1); 289 } 290 291 if (s.startsWith('.')) s = '0' + s; 292 if (s.endsWith('.')) s = s.slice(0,-1); 293 294 return sign + s; 295} 296 297function num(v){ 298 if (v === null || v === undefined) return NaN; 299 const s = normalizeLocaleNumberString(v); 300 if (!s || s === '+' || s === '-') return NaN; 301 const n = parseFloat(s); 302 return Number.isFinite(n) ? n : NaN; 303} 304 305function fmtDecClean(dec, maxDp=3){ 306 if (!Number.isFinite(dec)) return ''; 307 let s = dec.toFixed(maxDp); 308 s = s.replace(/(\.\d*?)0+$/,'$1').replace(/\.$/,''); 309 return s; 310} 311 312function normalizeOddsListSeparators(text){ 313 let s = String(text || '');
314 // Normalize common Unicode minus/dash characters that appear in copied odds lines. 315 s = s.replace(/[\u2212\u2013\u2014]/g, '-'); 316 // Replace common bullet characters with spaces. 317 s = s.replace(/[\u2022\u00B7â¢]/g, ' '); 318 let out = ''; 319 for (let i = 0; i < s.length; i++){ 320 const ch = s[i]; 321 const prev = i > 0 ? s[i-1] : ''; 322 const next = (i + 1 < s.length) ? s[i+1] : ''; 323 324 if (ch === ','){ 325 const prevIsDigit = prev >= '0' && prev <= '9'; 326 const nextIsDigit = next >= '0' && next <= '9'; 327 // Keep comma inside numbers (decimal comma), but treat other commas as separators. 328 out += (prevIsDigit && nextIsDigit) ? ',' : ' '; 329 continue; 330 } 331 332 // Common separators in pasted lines 333 if (ch === ';' || ch === '|' || ch === '\n' || ch === '\r' || ch === '\t'){ 334 out += ' '; 335 continue; 336 } 337 338 out += ch; 339 } 340 return out; 341} 342 343function parseOddsListFromText(text, max=20){ 344 // For Parlay/Dutch list inputs: 345 // - preserve the original order (mixed decimal / American / fractional) 346 // - allow decimal commas like "1,91" 347 // - treat comma/semicolon/newline as list separators 348 // - allow separators like "x" / "Ã" in pasted parlay lines 349 const base = normalizeOddsListSeparators(text); 350 351 // Convert common multipliers into separators (only when used as a separator) 352 const cleaned = base 353 .replace(/(\d)\s*[xÃ]\s*(\d)/gi, '$1 $2') 354 .replace(/\b[xÃ]\b/gi, ' '); 355 356 const out = []; 357 // Note: include 10-19 (e.g. 11.00 / 12.00) as valid decimal odds tokens. 358 // Also avoid accidentally capturing times like "12:30". 359 const re = /(\d+\s*\/\s*\d+|[+-]\s?\d{2,5}|[1-9]\d?[\.,]\d{1,3}|(?:100|[2-9]\d?|1\d))(?!\d)(?!\s*:\s*\d)/g; 360 361 let m; 362 while ((m = re.exec(cleaned)) !== null){ 363 const token = (m[1] || '').trim(); 364 if (!token) continue; 365 366 let dec = NaN; 367 368 if (token.includes('/')){ 369 dec = fracToDec(token); 370 } else if (token[0] === '+' || token[0] === '-'){ 371 const a = num(token.replace(/\s+/g,'')); 372 if (Number.isFinite(a) && Math.abs(a) >= 100 && Math.abs(a) <= 5000){ 373 dec = americanToDec(a); 374 } 375 } else { 376 dec = num(token); 377 } 378 379 if (Number.isFinite(dec) && dec > 1 && dec <= 100){ 380 out.push(dec); 381 if (out.length >= max) break; 382 } 383 } 384 385 return out; 386} 387 388// Find the first odds-like token in a messy string (decimal / American / fractional). 389// Returns { kind, token, index, decimal, decimalStr } or null. 390function findFirstOddsToken(text){ 391 const s = String(text ?? ''); 392 if (!s) return null; 393 394 let best = null; 395 396 function fmtDec(dec){ 397 if (!Number.isFinite(dec)) return ''; 398 // up to 4dp, trimmed 399 const s = dec.toFixed(4).replace(/0+$/,'').replace(/\.$/,''); 400 return s; 401 } 402 403 function consider(kind, token, index){ 404 if (index == null || index < 0) return; 405 const dec = oddsToDec(token); 406 if (!Number.isFinite(dec) || dec <= 1 || dec > 1000) return; 407 const obj = { 408 kind, 409 token: String(token).trim(), 410 index, 411 decimal: dec, 412 decimalStr: fmtDec(dec) 413 }; 414 if (!best || obj.index < best.index){ 415 best = obj; 416 } 417 } 418 419 // American (sign required) 420 { 421 const re = /[+-]\s*\d{2,5}/g; 422 const m = re.exec(s); 423 if (m) consider('american', m[0], m.index); 424 } 425 426 // Fractional 427 { 428 const re = /\b\d+\s*\/\s*\d+\b/g; 429 const m = re.exec(s); 430 if (m) consider('fractional', m[0], m.index); 431 } 432 433 // Decimal / integers 434 { 435 const re = /\b\d+(?:[\.,]\d{1,4})?\b/g; 436 let m; 437 while ((m = re.exec(s))){ 438 const tok = m[0]; 439 const dec = oddsToDec(tok); 440 if (Number.isFinite(dec) && dec > 1 && dec <= 1000){ 441 consider('decimal', tok, m.index); 442 break; // first valid decimal token in reading order 443 } 444 } 445 } 446 447 return best; 448} 449 450function formatOddsForConverterField(info, fieldOt){ 451 if (!info) return ''; 452 const ot = String(fieldOt || '').toLowerCase(); 453 454 if (ot === 'dec'){ 455 return info.kind === 'decimal' ? normalizeLocaleNumberString(info.token) : info.decimalStr; 456 } 457 if (ot === 'am'){ 458 if (info.kind === 'american'){ 459 return info.token.replace(/\s+/g,''); 460 } 461 const a = decToAmerican(info.decimal); 462 if (!Number.isFinite(a) || a === 0) return ''; 463 return (a > 0 ? '+' : '') + String(a); 464 } 465 if (ot === 'frac'){ 466 if (info.kind === 'fractional'){ 467 return info.token.replace(/\s+/g,''); 468 } 469 return decToFrac(info.decimal); 470 } 471 return info.decimalStr; 472} 473 474// Insert text into an input/textarea in a way that keeps browser undo (Ctrl+Z) working. 475function applyPasteText(el, text){ 476 const s = String(text ?? ''); 477 try{ 478 const start = typeof el.selectionStart === 'number' ? el.selectionStart : (el.value || '').length; 479 const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : (el.value || '').length; 480 if (typeof el.setRangeText === 'function'){ 481 el.setRangeText(s, start, end, 'end'); 482 return; 483 } 484 } catch (e) {} 485 486 // Fallback - may not preserve undo in some browsers, but better than merging junk. 487 try{ 488 document.execCommand('insertText', false, s); 489 return; 490 } catch (e) {} 491 492 el.value = s; 493} 494 495 496 497function normalizeOddsListText(text){ 498 const decs = parseOddsListFromText(text); 499 if (!decs.length) return ''; 500 return decs.map(d=>fmtDecClean(d,3)).join(', '); 501} 502 503function shouldNormalizeEl(el){ 504 if (!el || !el.tagName) return false;
505 const tag = el.tagName.toUpperCase(); 506 if (tag === 'INPUT'){ 507 // Explicit marker for single-odds inputs (used in Lay/Hedge/Free bet etc.) 508 // Allows smart paste + safe normalization even if data-ot overlaps with other tabs. 509 if (el.getAttribute('data-ot-odds') === '1') return true; 510 const ot = (el.getAttribute('data-ot') || '').toLowerCase(); 511 if (ot === 'frac') return false; 512 // Arb odds fields support smart paste (eg: "Home @ 1.91 Draw 3.40 Away 4.50"). 513 // If we normalize the whole pasted string, multiple decimals can get merged 514 // into one huge number (eg: 1913404.50). Skip normalization for Arb odds. 515 if (ot === 'a' || ot === 'b' || ot === 'c' || ot === 'o1' || ot === 'ox' || ot === 'o2') return false; 516 const im = (el.getAttribute('inputmode') || '').toLowerCase(); 517 return (im === 'decimal' || im === 'numeric'); 518 } 519 if (tag === 'TEXTAREA'){ 520 const ot = (el.getAttribute('data-ot') || '').toLowerCase(); 521 return (ot === 'legs' || ot === 'odds'); 522 } 523 return false; 524} 525 526function normalizeElementValue(el){ 527 if (!shouldNormalizeEl(el)) return; 528 529 const tag = el.tagName.toUpperCase(); 530 if (tag === 'TEXTAREA'){ 531 const ot = (el.getAttribute('data-ot') || '').toLowerCase(); 532 if (ot === 'legs' || ot === 'odds'){ 533 const norm = normalizeOddsListText(el.value); 534 if (norm) el.value = norm; 535 return; 536 } 537 } 538 539 const raw = String(el.value || '').trim(); 540 if (!raw) return; 541 542 // If a user pasted a full odds line (e.g. "Home @ 1.91 Draw 3.40 Away 4.50"), 543 // do NOT strip to digits and accidentally merge into a giant number. 544 if (tag !== 'TEXTAREA'){ 545 const ot = (el.getAttribute('data-ot') || '').toLowerCase(); 546 const isMarkedOdds = (el.getAttribute('data-ot-odds') === '1'); 547 const isOddsField = (ot === 'odds' || ot === 'dec' || ot === 'am' || isMarkedOdds); 548 if (isOddsField){ 549 if (/[A-Za-z@]/.test(raw) || /\s/.test(raw)){ 550 const info = findFirstOddsToken(raw); 551 if (info && info.decimalStr){ 552 if (ot === 'am'){ 553 const v = formatOddsForConverterField(info, 'am'); 554 if (v) { el.value = v; return; } 555 } else { 556 el.value = info.decimalStr; 557 return; 558 } 559 } 560 } 561 } 562 } 563 564 // keep fractions 565 if (/^\d+\s*\/\s*\d+$/.test(raw)){ 566 el.value = raw.replace(/\s+/g,''); 567 return; 568 } 569 570 const norm = normalizeLocaleNumberString(raw); 571 if (norm && norm !== raw){ 572 el.value = norm; 573 } 574} 575 576 function decToImp(dec){ 577 if (!Number.isFinite(dec) || dec <= 1) return NaN; 578 return 1/dec; 579 } 580 function decToAmerican(dec){ 581 if (!Number.isFinite(dec) || dec <= 1) return NaN; 582 const p = 1/dec; 583 // convert from decimal to american 584 if (dec >= 2) return Math.round((dec - 1) * 100); 585 return -Math.round(100 / (dec - 1)); 586 } 587 function americanToDec(a){ 588 if (!Number.isFinite(a) || a === 0) return NaN; 589 if (a > 0) return 1 + (a/100); 590 return 1 + (100/Math.abs(a)); 591 } 592 function fracToDec(fr){ 593 const m = String(fr).trim().match(/^(\d+)\s*\/\s*(\d+)$/); 594 if (!m) return NaN; 595 const n = parseInt(m[1],10), d=parseInt(m[2],10); 596 if (!d) return NaN; 597 return 1 + (n/d); 598 } 599 600 // Parse common odds formats into decimal odds. 601 // - Decimal: 1.91 / 2,05 / 2 602 // - American: +110 / -120 (sign required) 603 // - Fractional: 10/11 604 function oddsToDec(raw){ 605 const s0 = String(raw ?? '').trim(); 606 if (!s0) return NaN; 607 608 // Fractional 609 if (/^\d+\s*\/\s*\d+$/.test(s0)){ 610 return fracToDec(s0); 611 } 612 613 // American (sign required) 614 const s = s0.replace(/\u2212/g, '-'); // normalize Unicode minus 615 if (/^[+-]\s*\d{2,5}$/.test(s)){ 616 const a = num(s.replace(/\s+/g,'')); 617 if (Number.isFinite(a) && Math.abs(a) >= 100 && Math.abs(a) <= 5000){ 618 return americanToDec(a); 619 } 620 } 621 622 // Decimal / locale number 623 return num(s0); 624 } 625 function decToFrac(dec){ 626 if (!Number.isFinite(dec) || dec <= 1) return ''; 627 // approximate to common fractions with denom up to 20 628 const x = dec - 1; 629 let best = {n:1,d:1,err:Infinity}; 630 for (let d=1; d<=20; d++){ 631 const n = Math.round(x*d); 632 const err = Math.abs(x - (n/d)); 633 if (err < best.err){ 634 best = {n,d,err}; 635 } 636 } 637 return best.n + '/' + best.d; 638 } 639 640 function fmtPct(p){ 641 if (!Number.isFinite(p)) return 'â'; 642 return (p*100).toFixed(2) + '%'; 643 } 644 function fmtNum(n, dp=2){ 645 if (!Number.isFinite(n)) return 'â'; 646 return n.toFixed(dp); 647 } 648 function fmtMoney(n){ 649 if (!Number.isFinite(n)) return 'â'; 650 return n.toFixed(2); 651 } 652 653 function setInlineWarn(rootEl, msg){ 654 const el = qs('[data-ot="warn"]', rootEl); 655 if (!el) return; 656 if (msg){ 657 el.textContent = msg; 658 el.style.display = ''; 659 } else { 660 el.textContent = ''; 661 el.style.display = 'none'; 662 } 663 } 664 665 function hasBadOddsToken(text){ 666 const s = String(text || ''); 667 // standalone <= 1.00 odds tokens (0.xx, 1, 1.0, 1.00, 1,00 etc.) 668 // IMPORTANT: do NOT flag numbers like 1.91. 669 // We only want: 0.xx, 1, 1.0, 1.00, 1,00 ... (only zeros if a decimal exists). 670 // The extra negative lookahead prevents matching a bare "1" in "1.91". 671 const re = /(^|[^0-9])((?:0(?:[\.,]\d+)?|1(?:[\.,]0+)?))(?![\.,]\d)(?=[^0-9]|$)/; 672 return re.test(s); 673 } 674 675 function isBadStake(n){ 676 return Number.isFinite(n) && n < 0; 677 } 678 679 680 function toast(msg){ 681 let t = qs('.iceto-ot-toast'); 682 if (!t){ 683 t = document.createElement('div'); 684 t.className = 'iceto-ot-toast'; 685 document.body.appendChild(t); 686 } 687 t.textContent = msg; 688 t.classList.add('is-show'); 689 clearTimeout(t._to); 690 t._to = setTimeout(()=>
690t.classList.remove('is-show'), 1200); 691 } 692 693 function copyText(txt){ 694 if (!txt) return; 695 if (navigator.clipboard && navigator.clipboard.writeText){ 696 navigator.clipboard.writeText(txt).then(()=>toast('Copied')); 697 return; 698 } 699 const ta = document.createElement('textarea'); 700 ta.value = txt; 701 ta.style.position='fixed'; 702 ta.style.left='-9999px'; 703 document.body.appendChild(ta); 704 ta.select(); 705 try { document.execCommand('copy'); toast('Copied'); } catch(e){} 706 ta.remove(); 707 } 708 709 function parseOddsFromText(text){ 710 const s = String(text||''); 711 const out = []; 712 713 // decimal like 1.91 / 2.05 714 s.replace(/(^|[^\d])([1-9]\d?[\.,]\d{1,3})(?=[^\d]|$)/g, (_,p,v)=>{ 715 const n = num(v); 716 if (Number.isFinite(n) && n >= 1.01 && n <= 100) out.push({type:'dec', value:n}); 717 return _; 718 }); 719 720 // integer decimal like 2 / 10 / 100 (allow in parlays/dutching etc.) 721 // Avoid matching parts of decimals (2.10) or fraction parts (10/11). 722 s.replace(/(^|[^\d])([2-9]\d?|100)(?=[^\d]|$)/g, (m, p, v, off, str)=>{ 723 const prev = p || ''; 724 const next = str && typeof off === 'number' ? str.charAt(off + m.length) : ''; 725 if (prev === '.' || prev === ',' || prev === '/' || next === '.' || next === ',' || next === '/') return m; 726 const n = num(v); 727 if (Number.isFinite(n) && n >= 1.01 && n <= 100) out.push({type:'dec', value:n}); 728 return m; 729 }); 730 731 // american +110 / -125 732 s.replace(/(^|[^0-9])([+-]\s?\d{2,5})(?=[^0-9]|$)/g, (_,p,v)=>{ 733 const n = num(v.replace(/\s+/g,'')); 734 if (Number.isFinite(n) && Math.abs(n) >= 100 && Math.abs(n) <= 5000) out.push({type:'am', value:n}); 735 return _; 736 }); 737 738 // fractional 10/11 739 s.replace(/(^|[^\d])(\d+\s*\/\s*\d+)(?=[^\d]|$)/g, (_,p,v)=>{ 740 const d = fracToDec(v); 741 if (Number.isFinite(d)) out.push({type:'frac', value:v.trim()}); 742 return _; 743 }); 744 745 // normalize to decimals 746 const decs = out.map(o=>{ 747 if (o.type==='dec') return o.value; 748 if (o.type==='am') return americanToDec(o.value); 749 if (o.type==='frac') return fracToDec(o.value); 750 return NaN; 751 }).filter(n=>Number.isFinite(n) && n>1); 752 // keep duplicates (important for parlays/dutching where odds can repeat) 753 // cap to prevent pasted walls of numbers from bogging down the UI 754 return decs.slice(0, 20); 755 } 756 757 // --- UI bootstrap 758 // Dedicated page should work even if the FAB/root injection is missing for any reason. 759 const pageLauncher = qs('[data-iceto-ot-page="1"]'); 760 const fab = qs('[data-iceto-ot-fab="1"]'); 761 const root = qs('[data-iceto-ot-root="1"]'); 762 763 // Track viewport height for mobile popup sizing. 764 icetoBindViewportVar(); 765 766 // apply size + offset options (popup FAB only) 767 const size = clamp(num(CFG.buttonSize || '1.0') || 1.0, 0.75, 1.4); 768 const bottom = clamp(parseInt(CFG.bottomOffset||80,10) || 80, 10, 240); 769 if (fab){ 770 fab.style.transformOrigin = 'bottom right'; 771 fab.style.transform = `scale(${size})`; 772 fab.style.bottom = bottom + 'px'; 773 } 774 775 // page mode: if container exists, open as embedded panel 776 if (pageLauncher){ 777 // hide FAB on the dedicated page (cleaner) 778 if (fab) fab.style.display = 'none'; 779 applySchemeMarker(); 780 mountPanel({embedded:true, container: pageLauncher}); 781 } 782 783 // If we don't have the popup elements, stop here (page mode already mounted if present). 784 if (!fab || !root) return; 785 786 function openPanel(){ 787 if (qs('.iceto-ot-backdrop', root)) return; 788 icetoUpdateViewportVar(); 789 applySchemeMarker(); 790 mountPanel({embedded:false}); 791 fab.setAttribute('aria-expanded','true'); 792 } 793 794 function closePanel(){ 795 root.innerHTML = ''; 796 root.setAttribute('aria-hidden','true'); 797 fab.setAttribute('aria-expanded','false'); 798 fab.focus({preventScroll:true}); 799 } 800 801 function mountPanel({embedded, container}){ 802 applySchemeMarker(); 803 804 let wrap = null; 805 if (!embedded){ 806 if (!root) return; 807 root.setAttribute('aria-hidden', 'false'); 808 root.innerHTML = ''; 809 wrap = document.createElement('div'); 810 wrap.className = 'iceto-ot-rootWrap'; 811 812 const backdrop = document.createElement('div'); 813 backdrop.className = 'iceto-ot-backdrop'; 814 backdrop.addEventListener('click', closePanel); 815 wrap.appendChild(backdrop); 816 } else { 817 // Page mode: keep popup root hidden if it exists, but don't require it. 818 if (root){ 819 root.setAttribute('aria-hidden', 'true'); 820 root.innerHTML = ''; 821 } 822 } 823 824 const panel = document.createElement('div'); 825 panel.className = 'iceto-ot-panel'; 826 panel.classList.add(embedded ? 'iceto-ot-panel--embedded' : 'iceto-ot-panel--popup'); 827 if (embedded){ 828 panel.style.position = 'relative'; 829 panel.style.right = 'auto'; 830 panel.style.bottom = 'auto'; 831 panel.style.width = '100%'; 832 // In page mode we want natural height (the popup uses a fixed height). 833 panel.style.height = 'auto'; 834 panel.style.maxHeight = 'none';
835 panel.style.boxShadow = 'none'; 836 } else { 837 panel.style.bottom = (bottom + 58) + 'px'; 838 } 839 840 panel.addEventListener('click', (e)=>e.stopPropagation()); 841 842 panel.innerHTML = ` 843 <div class="iceto-ot-head"> 844 <div class="iceto-ot-title"> 845 <span>Odds tools</span> 846 <span class="iceto-ot-badge">Quick</span> 847 </div> 848 <div class="iceto-ot-actions"> 849 <button type="button" class="iceto-ot-btn" data-ot-action="copy">Copy line</button> 850 <button type="button" class="iceto-ot-btn" data-ot-action="reset">Reset</button> 851 ${embedded ? '' : '<button type="button" class="iceto-ot-btn" data-ot-action="page">Full page</button>'} 852 ${embedded ? '' : '<button type="button" class="iceto-ot-btn" data-ot-action="close">Close</button>'} 853 </div> 854 </div> 855 <div class="iceto-ot-tabs" role="tablist"></div> 856 <div class="iceto-ot-body"></div> 857 <div class="iceto-ot-ads${embedded ? ' iceto-ot-ads--page' : ''}" data-iceto-ot-ads="1"> 858 <a href="https://www.betting-forum.com/goto/everygame" rel="nofollow">Everygame</a> 859 <span class="sep">â¢</span> 860 <a href="https://www.betting-forum.com/goto/1xbet" rel="nofollow">1XBET</a> 861 <span class="sep">â¢</span> 862 <a href="https://www.betting-forum.com/goto/888sport" rel="nofollow">888 Sport</a> 863 </div> 864 `; 865 866 const tabsEl = qs('.iceto-ot-tabs', panel); 867 const bodyEl = qs('.iceto-ot-body', panel); 868 869 // Mobile tab row polish: 870 // - Fade edges when the tab row is horizontally scrollable 871 // - Auto-scroll the active tab into view (centered) on tap/switch 872 function updateTabsScrollState(){ 873 if (!tabsEl) return; 874 const scrollable = (tabsEl.scrollWidth > tabsEl.clientWidth + 2); 875 tabsEl.classList.toggle('iceto-ot-tabs--scrollable', scrollable); 876 if (!scrollable){ 877 tabsEl.classList.remove('iceto-ot-tabs--atStart','iceto-ot-tabs--atEnd'); 878 return; 879 } 880 const atStart = (tabsEl.scrollLeft <= 1); 881 const atEnd = (tabsEl.scrollLeft + tabsEl.clientWidth) >= (tabsEl.scrollWidth - 1); 882 tabsEl.classList.toggle('iceto-ot-tabs--atStart', atStart); 883 tabsEl.classList.toggle('iceto-ot-tabs--atEnd', atEnd); 884 } 885 886 function scrollActiveTabIntoView(){ 887 if (!tabsEl) return; 888 const btn = qs('.iceto-ot-tab.is-active', tabsEl); 889 if (!btn) return; 890 const scrollable = (tabsEl.scrollWidth > tabsEl.clientWidth + 2); 891 if (!scrollable) return; 892 893 const cRect = tabsEl.getBoundingClientRect(); 894 const bRect = btn.getBoundingClientRect(); 895 const targetCenter = (bRect.left + bRect.width / 2); 896 const containerCenter = (cRect.left + cRect.width / 2); 897 898 // Center the active tab without triggering page scroll. 899 tabsEl.scrollLeft += (targetCenter - containerCenter); 900 updateTabsScrollState(); 901 } 902 903 let _tabsScrollRAF = 0; 904 if (tabsEl){ 905 tabsEl.addEventListener('scroll', ()=>{ 906 if (_tabsScrollRAF) return; 907 _tabsScrollRAF = W.requestAnimationFrame(()=>{ 908 _tabsScrollRAF = 0; 909 updateTabsScrollState(); 910 }); 911 }, { passive:true }); 912 913 W.addEventListener('resize', ()=>{ updateTabsScrollState(); }, { passive:true }); 914 } 915 916 // Add a tiny "Copy" button to each KPI output card. 917 // This keeps the UI clean (button is only visible on hover/focus via CSS) 918 // and works for combined odds, stake splits, hedge amounts, etc. 919 function ensureResultCopyButtons(scope){ 920 qsa('.iceto-ot-kpi', scope).forEach(kpi=>{ 921 if (qs('.iceto-ot-copyResult', kpi)) return; 922 const btn = document.createElement('button'); 923 btn.type = 'button'; 924 btn.className = 'iceto-ot-copyResult'; 925 btn.textContent = 'Copy'; 926 btn.setAttribute('aria-label', 'Copy result'); 927 kpi.appendChild(btn); 928 }); 929 } 930 931// Locale-safe normalization: normalize on paste and on blur (not on each keystroke). 932// This keeps typing smooth, but prevents mixed formats like "1.000,50" from breaking calculations. 933panel.addEventListener('paste', function(e) { 934 const el = e.target; 935 if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) return; 936 937 const ot = (el.getAttribute('data-ot') || '').toLowerCase(); 938 const clip = e.clipboardData && e.clipboardData.getData('text/plain'); 939 if (!clip) return; 940 941 // Record an undo snapshot for paste actions. We often normalize pasted text 942 // (either immediately or in a setTimeout), which can break native undo. 943 pushUndo(el); 944
945 // For single-odds inputs, prevent "digit merge" from messy lines and keep Ctrl+Z undo. 946 // - Odds converter fields: dec / am / frac 947 // - Any-format single odds inputs (Kelly, Value, Lay, Free bet, etc.): data-ot="odds" (INPUT only) 948 const isConverter = (ot === 'dec' || ot === 'am' || ot === 'frac'); 949 const isSingleAny = (ot === 'odds' && el.tagName.toUpperCase() === 'INPUT'); 950 const isMarkedOdds = (el.getAttribute('data-ot-odds') === '1' && el.tagName.toUpperCase() === 'INPUT'); 951 952 const isSingleOddsInput = (isSingleAny || isMarkedOdds); 953 954 if (isConverter || isSingleOddsInput){ 955 const info = findFirstOddsToken(clip); 956 if (info && info.decimal > 1){ 957 e.preventDefault(); 958 959 const insertVal = isConverter 960 ? formatOddsForConverterField(info, ot) 961 : info.decimalStr; 962 963 applyPasteText(el, insertVal); 964 965 // Trigger calculations / sync on the same tick. 966 try{ 967 el.dispatchEvent(new Event('input', {bubbles:true})); 968 } catch (err) {} 969 970 return; 971 } 972 } 973 974 // Default behaviour + post-normalize 975 if (!shouldNormalizeEl(el)) return; 976 977 setTimeout(function() { 978 normalizeElementValue(el);
979 try{ el.dispatchEvent(new Event('input', {bubbles: true})); } catch (err) {} 980 }, 0); 981 }, true); 982 983panel.addEventListener('blur', function(e){ 984 const el = e.target; 985 if (!shouldNormalizeEl(el)) return; 986 normalizeElementValue(el); 987 try { el.dispatchEvent(new Event('input', { bubbles:true })); } catch(ex){} 988}, true); 989 990 991// Keyboard flow: Enter jumps to the next field (skips textareas). 992panel.addEventListener('keydown', function(e){ 993 if (e.key !== 'Enter') return; 994 const el = e.target; 995 if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement)) return; 996 // Do not hijack Enter inside multiline lists (textareas). 997 if (el.tagName && el.tagName.toUpperCase() === 'TEXTAREA') return; 998 999 // Only handle Enter for fields inside the tool body (not header buttons). 1000 if (bodyEl && !bodyEl.contains(el)) return; 1001 1002 e.preventDefault(); 1003 1004 const scope = bodyEl || panel; 1005 const focusables = qsa('input, select', scope).filter(n=>{ 1006 if (!(n instanceof HTMLElement)) return false; 1007 if (n instanceof HTMLInputElement){ 1008 if (n.type === 'hidden') return false; 1009 if (n.disabled || n.readOnly) return false; 1010 } else { 1011 if ((n).disabled) return false; 1012 } 1013 // Skip elements not visible (display:none) or detached 1014 if (!n.offsetParent) return false; 1015 return true; 1016 }); 1017 1018 const idx = focusables.indexOf(el); 1019 if (idx < 0) return; 1020 1021 const next = focusables[idx + 1]; 1022 if (next){ 1023 try{ next.focus({preventScroll:true}); } catch(ex){ next.focus(); } 1024 if (next instanceof HTMLInputElement){ 1025 try{ next.select(); } catch(ex){} 1026 } 1027 } else { 1028 try{ el.blur(); } catch(ex){} 1029 } 1030}, true); 1031 1032 const order = String(CFG.tabOrder || '').split(',').map(s=>s.trim()).filter(Boolean); 1033 const tabs = (order.length ? order : TAB_DEFS.map(t=>t.id)).filter(id=>TAB_MAP[id]).map(id=>TAB_MAP[id]); 1034 1035 const KEY_LAST = 'icetoOddsTools:lastTab'; 1036 const prefer = (CFG.defaultTab || '').trim(); 1037 const last = W.localStorage ? (localStorage.getItem(KEY_LAST) || '') : ''; 1038 const initial = (tabs.find(t=>t.id===prefer) ? prefer : (tabs.find(t=>t.id===last) ? last : tabs[0].id)); 1039 1040 let active = null; 1041 1042 function setActive(id){ 1043 const t = TAB_MAP[id] ? id : tabs[0].id; 1044 active = t; 1045 ACTIVE_TAB_ID = t; 1046 try { panel.dataset.activeTab = t; } catch(e){} 1047 qsa('.iceto-ot-tab', tabsEl).forEach(btn=>{ 1048 btn.classList.toggle('is-active', btn.dataset.tab === t); 1049 btn.setAttribute('aria-selected', btn.dataset.tab === t ? 'true' : 'false'); 1050 }); 1051 bodyEl.innerHTML = ''; 1052 TAB_MAP[t].render(bodyEl); 1053 ensureResultCopyButtons(bodyEl); 1054 // Keep the active tab visible on mobile and update fade edges. 1055 scrollActiveTabIntoView(); 1056 updateTabsScrollState(); 1057 if (W.localStorage) localStorage.setItem(KEY_LAST, t); 1058 } 1059
1060 tabs.forEach((t)=>{ 1061 const b = document.createElement('button'); 1062 b.type = 'button'; 1063 b.className = 'iceto-ot-tab'; 1064 b.dataset.tab = t.id; 1065 b.textContent = t.label; 1066 b.setAttribute('role','tab'); 1067 b.addEventListener('click', ()=>setActive(t.id)); 1068 tabsEl.appendChild(b); 1069 }); 1070 1071 panel.addEventListener('keydown', (e)=>{ 1072 const t = e.target; 1073 // Custom undo for paste-normalized fields. 1074 if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')){ 1075 if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement){ 1076 const prev = popUndo(t); 1077 if (prev !== null){ 1078 e.preventDefault(); 1079 t.value = prev; 1080 try{ t.dispatchEvent(new Event('input', {bubbles:true})); } catch(err){} 1081 return; 1082 } 1083 } 1084 } 1085 if (!embedded && e.key === 'Escape'){ e.preventDefault(); closePanel(); } 1086 }); 1087 1088 // action buttons 1089 const actCopy = qs('[data-ot-action="copy"]', panel); 1090 const actReset = qs('[data-ot-action="reset"]', panel); 1091 const actPage = qs('[data-ot-action="page"]', panel); 1092 const actClose = qs('[data-ot-action="close"]', panel); 1093 1094 function resetActiveTab(){ 1095 const scope = bodyEl || panel; 1096 // Clear only the active tool fields (inputs + textareas). Do not change tab selection. 1097 qsa('input, textarea', scope).forEach(el=>{ 1098 if (el.disabled || el.readOnly) return; 1099 const type = (el.getAttribute('type') || '').toLowerCase(); 1100 if (type === 'checkbox' || type === 'radio'){ 1101 el.checked = false; 1102 try{ el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){} 1103 return; 1104 } 1105 el.value = ''; 1106 try{ el.dispatchEvent(new Event('input', {bubbles:true})); } catch(e){} 1107 try{ el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){} 1108 }); 1109
1110 // Hide inline warning boxes if present. 1111 const w = qs('[data-ot="warn"]', scope); 1112 if (w){ w.textContent = ''; w.style.display = 'none'; } 1113 } 1114 1115 if (actCopy) actCopy.addEventListener('click', ()=>{ 1116 const line = buildCleanOddsLine(); 1117 if (!line){ toast('Nothing to copy'); return; } 1118 copyText(line); 1119 }); 1120 if (actReset) actReset.addEventListener('click', ()=>{ 1121 resetActiveTab(); 1122 toast('Cleared'); 1123 }); 1124 if (actPage) actPage.addEventListener('click', ()=>{ 1125 try { W.location.href = (CFG.pageUrl || '/odds-calculator'); } catch(e){} 1126 }); 1127 if (actClose) actClose.addEventListener('click', closePanel); 1128 1129 // Per-KPI copy buttons 1130 panel.addEventListener('click', (e)=>{ 1131 const btn = e.target && e.target.closest ? e.target.closest('.iceto-ot-copyResult') : null; 1132 if (!btn) return; 1133 e.preventDefault(); 1134 e.stopPropagation(); 1135 const kpi = btn.closest('.iceto-ot-kpi'); 1136 const valEl = kpi ? qs('.v', kpi) : null; 1137 const txt = valEl ? String(valEl.textContent || '').trim() : ''; 1138 if (!txt || txt === 'â'){ 1139 toast('Nothing to copy'); 1140 return; 1141 } 1142 copyText(txt); 1143 }); 1144 1145 // mount 1146 if (embedded){ 1147 container.innerHTML = ''; 1148 container.appendChild(panel); 1149 } else { 1150 wrap.appendChild(panel); 1151 root.appendChild(wrap); 1152 } 1153 1154 setActive(initial); 1155 1156 // Focus trap (lightweight) 1157 if (!embedded){ 1158 setTimeout(()=>{ 1159 const f = panel.querySelector('input,textarea,select,button'); 1160 if (f) f.focus({preventScroll:true}); 1161 }, 0); 1162 } 1163 } 1164 1165 // Event bindings for FAB 1166 fab.addEventListener('click', openPanel); 1167 fab.addEventListener('keydown', (e)=>{ 1168 if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openPanel(); } 1169 }); 1170 1171 1172 function tryMountDedicated(context){ 1173 const ctx = (context && context.querySelector) ? context : document; 1174 const pl = ctx.querySelector('[data-iceto-ot-page="1"]'); 1175 if (!pl) return; 1176 if (pl.getAttribute('data-iceto-ot-mounted') === '1') return; 1177 pl.setAttribute('data-iceto-ot-mounted', '1'); 1178 1179 const fabNow = document.querySelector('[data-iceto-ot-fab="1"]'); 1180 if (fabNow) fabNow.style.display = 'none'; 1181 1182 applySchemeMarker(); 1183 mountPanel({embedded:true, container: pl}); 1184 } 1185 1186 // Mount embedded panel on initial load (direct visit). 1187 tryMountDedicated(document); 1188 1189 // Mount embedded panel when XenForo swaps in new content via AJAX navigation. 1190 document.addEventListener('xf:load', function(e){ 1191 tryMountDedicated((e && e.target) ? e.target : document); 1192 }); 1193 1194 // --- Copy line (smart per tool) 1195 function buildCleanOddsLine(){ 1196 // NOTE: this is intentionally a "copy everything" builder, not only odds. 1197 // It upgrades the existing "Copy line" button to output a complete, tab-aware summary. 1198 const panel = qs('.iceto-ot-panel'); 1199 if (!panel) return ''; 1200 1201 const tabEl = qs('.iceto-ot-body', panel) || panel; 1202 const tabId = (ACTIVE_TAB_ID || (panel.dataset ? panel.dataset.activeTab : '') || '').toLowerCase(); 1203 1204 const txt = (sel) => { 1205 const el = qs(sel, tabEl); 1206 return el ? String(el.textContent || '').trim() : ''; 1207 }; 1208 const val = (sel) => { 1209 const el = qs(sel, tabEl); 1210 return el ? String(el.value || '').trim() : ''; 1211 }; 1212 1213 const kpis = () => qsa('.iceto-ot-kpi', tabEl).map(k => { 1214 const kk = (qs('.k', k) ? qs('.k', k).textContent : '').trim(); 1215 const vv = (qs('.v', k) ? qs('.v', k).textContent : '').trim(); 1216 return { k: kk, v: vv }; 1217 }).filter(p => p.v && p.v !== 'â'); 1218 1219 const joinPairs = (pairs) => pairs.map(p => (p.k ? (p.k + ' ' + p.v) : p.v)).join(' | '); 1220 1221 const fmtOddsList = (odds, sep) => (odds || []).map(o => fmtNum(o, 2)).join(sep || ' '); 1222 1223 // --- Parlay: 1.90 x 2.30 x 1.70 = 7.429 | stake 100 | return 742.90 | profit 642.90 1224 if (tabId === 'parlay'){ 1225 const odds = parseOddsListFromText(val('[data-ot="list"]'), 20); 1226 if (odds.length < 2) return ''; 1227 const comb = txt('[data-ot-k="odds"]'); 1228 const be = txt('[data-ot-k="be"]'); 1229 const imp = txt('[data-ot-k="imp"]'); 1230 const stakeRaw = val('[data-ot="stake"]'); 1231 const ret = txt('[data-ot-k="ret"]'); 1232 const prof = txt('[data-ot-k="prof"]'); 1233 1234 const parts = [];
1235 const main = fmtOddsList(odds, ' x ') + ((comb && comb !== 'â') ? (' = ' + comb) : ''); 1236 parts.push(main); 1237 1238 if (imp && imp !== 'â') parts.push('implied ' + imp); 1239 if (be && be !== 'â') parts.push('break-even ' + be); 1240 1241 const st = num(stakeRaw); 1242 const hasStake = Number.isFinite(st) && st > 0; 1243 if (hasStake){ 1244 parts.push('stake ' + fmtMoney(st)); 1245 if (ret && ret !== 'â') parts.push('return ' + ret); 1246 if (prof && prof !== 'â') parts.push('profit ' + prof); 1247 } 1248 1249 const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : ''; 1250 const prefix = label ? (label + ': ') : ''; 1251 return prefix + parts.join(' | '); 1252 } 1253 1254 // --- Dutch: list odds + stake split + guaranteed return 1255 if (tabId === 'dutch'){ 1256 const odds = parseOddsListFromText(val('[data-ot="odds"]'), 20); 1257 if (!odds.length) return ''; 1258 const stakeRaw = val('[data-ot="s"]'); 1259 const st = num(stakeRaw); 1260 const hasStake = Number.isFinite(st) && st > 0; 1261 1262 const split = txt('[data-ot-k="split"]'); 1263 const ret = txt('[data-ot-k="ret"]'); 1264 const prof = txt('[data-ot-k="prof"]'); 1265 1266 const parts = []; 1267 1268 if (!hasStake){ 1269 // If there is no stake entered, do not include stake-dependent outputs (split/return/profit). 1270 parts.push(fmtOddsList(odds, ' / ')); 1271 } else { 1272 // Pair odds with stakes when possible 1273 let paired = ''; 1274 if (split && split !== 'â'){ 1275 const stakeParts = split.split('/').map(s => s.trim()).filter(Boolean); 1276 if (stakeParts.length === odds.length){ 1277 paired = odds.map((o,i)=>fmtNum(o,2) + '=' + stakeParts[i]).join(' / '); 1278 } 1279 } 1280 1281 if (paired){ 1282 parts.push(paired); 1283 } else { 1284 parts.push(fmtOddsList(odds, ' / ')); 1285 if (split && split !== 'â') parts.push('split ' + split); 1286 } 1287 1288 parts.push('stake ' + fmtMoney(st)); 1289 if (ret && ret !== 'â') parts.push('guaranteed return ' + ret); 1290 if (prof && prof !== 'â') parts.push('profit ' + prof); 1291 } 1292 1293 const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : ''; 1294 const prefix = label ? (label + ': ') : ''; 1295 return prefix + parts.join(' | '); 1296 } 1297 1298 // --- Arb: include odds + stakes + profit % 1299 if (tabId === 'arb'){ 1300 const mode = val('[data-ot="mode"]') || ''; 1301 const a = val('[data-ot="a"]'); 1302 const b = val('[data-ot="b"]'); 1303 const c = val('[data-ot="c"]'); 1304 1305 const stakeRaw = val('[data-ot="s"]'); 1306 const st = num(stakeRaw); 1307 const hasStake = Number.isFinite(st) && st > 0; 1308 1309 const oddsPicked = parseOddsFromText([a,b,c].filter(Boolean).join(' ')); 1310 const odds = (mode === '2way') ? oddsPicked.slice(0,2) : oddsPicked.slice(0,3); 1311 1312 if (!odds.length) return ''; 1313 1314 const parts = []; 1315 parts.push('odds ' + fmtOddsList(odds, ' / ')); 1316 1317 const arbPct = txt('[data-ot-k="arb"]'); 1318 const profitPct = txt('[data-ot-k="profit"]'); 1319 const totRet = txt('[data-ot-k="ret"]'); 1320 1321 if (arbPct && arbPct !== 'â') parts.push('arb ' + arbPct); 1322 if (profitPct && profitPct !== 'â') parts.push('profit ' + profitPct); 1323 if (totRet && totRet !== 'â') parts.push('total return ' + totRet); 1324 1325 if (hasStake){ 1326 parts.push('stake ' + fmtMoney(st)); 1327 1328 // Prefer the detailed per-outcome rows if available 1329 const rows = qsa('.iceto-ot-returnsRow', tabEl); 1330 if (rows && rows.length){ 1331 const rowTxt = rows.map(r => { 1332 const cells = Array.from(r.children).map(x => (x.textContent || '').trim()); 1333 // [outcome, stake, return, profit] 1334 if (cells.length >= 4 && cells[1] && cells[1] !== 'â'){ 1335 const out = []; 1336 out.push(cells[0]); 1337 out.push('stake ' + cells[1]); 1338 if (cells[2] && cells[2] !== 'â') out.push('return ' + cells[2]); 1339 if (cells[3] && cells[3] !== 'â') out.push('profit ' + cells[3]); 1340 return out.join(' '); 1341 } 1342 return ''; 1343 }
1343).filter(Boolean).join(' / '); 1344 if (rowTxt) parts.push(rowTxt); 1345 } else { 1346 const stLine = txt('[data-ot-k="st"]'); 1347 if (stLine && stLine !== 'â') parts.push('stakes ' + stLine); 1348 } 1349 } 1350 1351 const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : ''; 1352 const prefix = label ? (label + ': ') : ''; 1353 return prefix + parts.join(' | '); 1354 } 1355 1356 // --- Odds / Margin / others: include inputs + KPI outputs 1357 // For these tools, "copy everything" is best represented as: 1358 // Inputs (only if non-empty) + outputs (KPI cards). 1359 const pairs = []; 1360 1361 // Try to capture the main inputs in a stable order per tool. 1362 const pushInput = (label, raw) => { 1363 const v = String(raw || '').trim(); 1364 if (!v) return; 1365 pairs.push({ k: label + ':', v }); 1366 }; 1367 1368 if (tabId === 'odds'){ 1369 pushInput('Decimal', val('[data-ot="dec"]')); 1370 pushInput('American', val('[data-ot="am"]')); 1371 pushInput('Fractional', val('[data-ot="frac"]')); 1372 } else if (tabId === 'margin'){ 1373 pushInput('Side A', val('[data-ot="a"]')); 1374 pushInput('Side B', val('[data-ot="b"]')); 1375 } else if (tabId === 'hedge'){ 1376 pushInput('Initial stake', val('[data-ot="s"]')); 1377 pushInput('Initial odds', val('[data-ot="o1"]')); 1378 pushInput('Hedge odds', val('[data-ot="o2"]')); 1379 } else if (tabId === 'kelly'){ 1380 pushInput('Odds', val('[data-ot="odds"]')); 1381 pushInput('Win %', val('[data-ot="p"]')); 1382 pushInput('Bankroll', val('[data-ot="br"]')); 1383 } else if (tabId === 'value'){ 1384 pushInput('Odds', val('[data-ot="odds"]')); 1385 pushInput('Prob %', val('[data-ot="p"]')); 1386 pushInput('Stake', val('[data-ot="s"]')); 1387 } else if (tabId === 'lay'){ 1388 pushInput('Back odds', val('[data-ot="bo"]')); 1389 pushInput('Back stake', val('[data-ot="bs"]')); 1390 pushInput('Lay odds', val('[data-ot="lo"]')); 1391 } else if (tabId === 'freebet'){ 1392 pushInput('Free bet stake', val('[data-ot="fb"]')); 1393 pushInput('Back odds', val('[data-ot="bo"]')); 1394 pushInput('Lay odds', val('[data-ot="lo"]')); 1395 } 1396 1397 const stakeGuess = (()=>{ 1398 const sels = ['[data-ot="stake"]','[data-ot="s"]','[data-ot="st"]','[data-ot="bet"]','[data-ot="fb"]']; 1399 for (const sel of sels){ 1400 const n = num(val(sel)); 1401 if (Number.isFinite(n) && n > 0) return n; 1402 } 1403 return NaN; 1404 })(); 1405 const hasStake = Number.isFinite(stakeGuess) && stakeGuess > 0; 1406 1407 const isStakeKey = (k)=>/stake/i.test(k || ''); 1408 const isReturnKey = (k)=>/(return|payout|guaranteed return|equal return)/i.test(k || ''); 1409 const isProfitKey = (k)=>/(profit|net profit)/i.test(k || ''); 1410 const isStakeRelated = (k)=>/(stake|return|profit|payout|liability|split)/i.test(k || ''); 1411 1412 let out = kpis(); 1413 1414 // Ensure stake/return/profit appear only when stake exists, and in the same order everywhere. 1415 if (!hasStake){ 1416 out = out.filter(p => !isStakeRelated(p.k)); 1417 } else { 1418 const stakeParts = out.filter(p => isStakeKey(p.k)); 1419 const returnParts = out.filter(p => isReturnKey(p.k)); 1420 const profitParts = out.filter(p => isProfitKey(p.k)); 1421 const rest = out.filter(p => !(isStakeKey(p.k) || isReturnKey(p.k) || isProfitKey(p.k))); 1422 out = rest.concat(stakeParts, returnParts, profitParts); 1423 } 1424 1425 const outTxt = joinPairs(out); 1426 1427 const inputTxt = pairs.length ? joinPairs(pairs) : ''; 1428 const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : ''; 1429 const prefix = label ? (label + ': ') : ''; 1430 1431 const full = [inputTxt, outTxt].filter(Boolean).join(' | '); 1432 return full ? (prefix + full) : ''; 1433 } 1434 1435 // --- Renderers 1436 1437 function renderOdds(rootEl){ 1438 rootEl.innerHTML = ` 1439 <div class="iceto-ot-row"> 1440 <div class="iceto-ot-field"> 1441 <label>Decimal</label> 1442 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.91" data-ot="dec"> 1443 </div> 1444 <div class="iceto-ot-field"> 1445 <label>American</label> 1446 <input type="text" inputmode="numeric" enterkeyhint="next" placeholder="-110" data-ot="am"> 1447 </div> 1448 <div class="iceto-ot-field"> 1449 <label>Fractional</label> 1450 <input type="text" inputmode="numeric" enterkeyhint="next" placeholder="10/11" data-ot="frac"> 1451 </div> 1452 </div> 1453 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 1454 <div class="iceto-ot-kpis"> 1455 <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">â</div></div> 1456 <div class="iceto-ot-kpi"><div class="k">Fair odds (no margin)</div><div class="v" data-ot-k="fair">â</div></div> 1457 </div> 1458 <div class="iceto-ot-note">Tip: Paste any odds text into any box. The tool will auto-detect and fill the best match.</div> 1459 `; 1460 1461 const decI = qs('[data-ot="dec"]', rootEl); 1462 const amI = qs('[data-ot="am"]', rootEl); 1463 const frI = qs('[data-ot="frac"]', rootEl); 1464 1465 function safeSet(el, v, force){ 1466 if (force || document.activeElement !== el){ 1467 el.value = v; 1468 } 1469 } 1470 1471 function sync(from, opts){ 1472 opts = opts || {}; 1473 const forceFormat = !!opts.forceFormat; 1474 let dec = NaN; 1475 1476 if (from === 'dec'){ 1477 dec = num(decI.value); 1478 if (Number.isFinite(dec) && dec < 1.01) dec = NaN; 1479 } 1480 if (from === 'am'){ 1481 const a = num(amI.value); 1482 if (Number.isFinite(a) && Math.abs(a) >= 100){ 1483 dec = americanToDec(a); 1484 } 1485 } 1486 if (from === 'frac'){ 1487 // Only accept a fully-formed fraction while typing 1488 const v = String(frI.value || '').trim(); 1489 if (/^\d+\s*\/\s*\d+$/.test(v)){ 1490 dec = fracToDec(v); 1491 } 1492 } 1493 1494 // Paste detection (auto) 1495 if (!Number.isFinite(dec)){ 1496 const pick = parseOddsFromText(from === 'dec' ? decI.value : from==='am'? amI.value : frI.value)[0]; 1497 if (Number.isFinite(pick)) dec = pick; 1498 } 1499 1500 if (Number.isFinite(dec)){ 1501 setInlineWarn(rootEl, '');
1502 // Do not reformat the field the user is actively typing in. 1503 // Reformat on blur (or when forced). 1504 safeSet(decI, fmtNum(dec, 2), forceFormat); 1505 const am = decToAmerican(dec); 1506 safeSet(amI, Number.isFinite(am) ? (am>0 ? '+'+am : String(am)) : '', forceFormat); 1507 safeSet(frI, decToFrac(dec), forceFormat); 1508 1509 const imp = decToImp(dec); 1510 qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(imp); 1511 qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(1/imp, 2); 1512 } else { 1513 qs('[data-ot-k="imp"]', rootEl).textContent = 'â'; 1514 qs('[data-ot-k="fair"]', rootEl).textContent = 'â'; 1515 const any = String(decI.value||'').trim() || String(amI.value||'').trim() || String(frI.value||'').trim(); 1516 setInlineWarn(rootEl, any ? 'Invalid odds. Use decimal > 1.00, American +/-100+, or fractional 10/11.' : ''); 1517 } 1518 } 1519 1520 decI.addEventListener('input', ()=>sync('dec')); 1521 amI.addEventListener('input', ()=>sync('am')); 1522 frI.addEventListener('input', ()=>sync('frac')); 1523 1524 // Format only after the user finishes editing. 1525 decI.addEventListener('blur', ()=>sync('dec', {forceFormat:true})); 1526 amI.addEventListener('blur', ()=>sync('am', {forceFormat:true})); 1527 frI.addEventListener('blur', ()=>sync('frac', {forceFormat:true})); 1528 } 1529 1530 function renderProb(rootEl){ 1531 rootEl.innerHTML = ` 1532 <div class="iceto-ot-row"> 1533 <div class="iceto-ot-field"> 1534 <label>Odds (any format)</label> 1535 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.91" data-ot="dec"> 1536 </div> 1537 <div class="iceto-ot-field"> 1538 <label>Implied probability (%)</label> 1539 <input type="text" inputmode="decimal" placeholder="52.36" data-ot="pct"> 1540 </div> 1541 </div> 1542 <div class="iceto-ot-kpis"> 1543 <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">â</div></div> 1544 <div class="iceto-ot-kpi"><div class="k">Fair odds</div><div class="v" data-ot-k="fair">â</div></div> 1545 </div> 1546 <div class="iceto-ot-note">If you paste text with odds, we will detect the first valid odds and calculate implied probability.</div> 1547 `; 1548 const decI = qs('[data-ot="dec"]', rootEl); 1549 const pctI = qs('[data-ot="pct"]', rootEl); 1550 1551 function fromDec(){ 1552 let dec = num(decI.value); 1553 if (!Number.isFinite(dec)){ 1554 const pick = parseOddsFromText(decI.value)[0]; 1555 if (Number.isFinite(pick)) dec = pick; 1556 } 1557 const imp = decToImp(dec); 1558 if (Number.isFinite(imp)){ 1559 pctI.value = (imp*100).toFixed(2); 1560 qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(imp); 1561 qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(1/imp,2); 1562 } 1563 } 1564 function fromPct(){ 1565 const p = num(pctI.value) / 100; 1566 if (Number.isFinite(p) && p>0 && p<1){ 1567 const dec = 1/p; 1568 decI.value = dec.toFixed(2); 1569 qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(p); 1570 qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(dec,2); 1571 } 1572 } 1573 decI.addEventListener('input', fromDec); 1574 pctI.addEventListener('input', fromPct); 1575 } 1576 1577 function renderMargin(rootEl){ 1578 rootEl.innerHTML = ` 1579 <div class="iceto-ot-row"> 1580 <div class="iceto-ot-field"> 1581 <label>Side A odds (any format)</label> 1582 <input type="text" inputmode="decimal" placeholder="1.91" data-ot="a"> 1583 </div> 1584 <div class="iceto-ot-field"> 1585 <label>Side B odds (any format)</label> 1586 <input type="text" inputmode="decimal" placeholder="1.91" data-ot="b"> 1587 </div> 1588 </div> 1589 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 1590 <div class="iceto-ot-kpis"> 1591 <div class="iceto-ot-kpi"><div class="k">Overround (margin)</div><div class="v" data-ot-k="m">â</div></div> 1592 <div class="iceto-ot-kpi"><div class="k">Fair A / Fair B</div><div class="v" data-ot-k="fair">â</div></div> 1593 </div> 1594 <div class="iceto-ot-note">Tip: click Copy line after pasting messy odds text into either field.</div> 1595 `; 1596 const aI = qs('[data-ot="a"]', rootEl); 1597 const bI = qs('[data-ot="b"]', rootEl); 1598 1599 function calc(){ 1600 setInlineWarn(rootEl, ''); 1601 let a = oddsToDec(aI.value), b = oddsToDec(bI.value); 1602 1603 if (!Number.isFinite(a) || !Number.isFinite(b)){ 1604 const picks = parseOddsFromText((aI.value||'')+' '+(bI.value||'')); 1605 if (picks.length >= 2){ 1606 a = picks[0]; b = picks[1]; 1607 aI.value = a.toFixed(2); 1608 bI.value = b.toFixed(2); 1609 } 1610 } 1611 1612 const ia = decToImp(a), ib = decToImp(b); 1613 if (Number.isFinite(ia) && Number.isFinite(ib)){ 1614 const over = ia + ib; 1615 const margin = over - 1; 1616 qs('[data-ot-k="m"]', rootEl).textContent = (margin*100).toFixed(2) + '%'; 1617 const fa = ia / over; 1618 const fb = ib / over; 1619 const fairA = 1/fa, fairB = 1/fb;
1620 qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(fairA,2) + ' / ' + fmtNum(fairB,2); 1621 } else { 1622 qs('[data-ot-k="m"]', rootEl).textContent = 'â'; 1623 qs('[data-ot-k="fair"]', rootEl).textContent = 'â'; 1624 const any = String(aI.value||'').trim() || String(bI.value||'').trim(); 1625 if (any) setInlineWarn(rootEl, 'Enter two valid odds (both greater than 1.00).'); 1626 } 1627 } 1628 1629 aI.addEventListener('input', calc); 1630 bI.addEventListener('input', calc); 1631 } 1632 1633 function renderArb(rootEl){ 1634 rootEl.innerHTML = ` 1635<div class="iceto-ot-row"> 1636 <div class="iceto-ot-field" style="flex:1 1 180px; min-width: 180px;"> 1637 <label>Mode</label> 1638 <select data-ot="mode"> 1639 <option value="1x2">1X2 (football)</option> 1640 <option value="2way">2-way</option> 1641 </select> 1642 </div> 1643 <div class="iceto-ot-field" style="flex:1 1 140px;"> 1644 <label>Commission % (optional)</label> 1645 <input type="text" inputmode="decimal" placeholder="0" data-ot="comm"> 1646 </div> 1647 <div class="iceto-ot-field" style="flex:1 1 160px;"> 1648 <label>Round stakes</label> 1649 <select data-ot="round"> 1650 <option value="0">No rounding</option> 1651 <option value="0.01">0.01</option> 1652 <option value="0.1">0.1</option> 1653 <option value="0.5">0.5</option> 1654 <option value="1">1</option> 1655 <option value="5">5</option> 1656 </select> 1657 </div> 1658</div> 1659 1660<div class="iceto-ot-row"> 1661 <div class="iceto-ot-field"> 1662 <label data-ot-ol="a">Odds 1 (Home)</label> 1663 <input type="text" inputmode="decimal" placeholder="2.00" data-ot="a"> 1664 </div> 1665 <div class="iceto-ot-field"> 1666 <label data-ot-ol="b">Odds X (Draw)</label> 1667 <input type="text" inputmode="decimal" placeholder="2.00" data-ot="b"> 1668 </div> 1669 <div class="iceto-ot-field" data-ot-wrap="c"> 1670 <label data-ot-ol="c">Odds 2 (Away)</label> 1671 <input type="text" inputmode="decimal" placeholder="2.00" data-ot="c"> 1672 </div> 1673 <div class="iceto-ot-field"> 1674 <label>Total stake</label> 1675 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s"> 1676 </div> 1677</div> 1678 1679<div class="iceto-ot-row"> 1680 <div class="iceto-ot-field"> 1681 <label>Max total stake (optional)</label> 1682 <input type="text" inputmode="decimal" placeholder="-" data-ot="maxTotal"> 1683 </div> 1684 <div class="iceto-ot-field"> 1685 <label data-ot-maxl="a">Max stake 1 (optional)</label> 1686 <input type="text" inputmode="decimal" placeholder="-" data-ot="maxA"> 1687 </div> 1688 <div class="iceto-ot-field"> 1689 <label data-ot-maxl="b">Max stake X (optional)</label> 1690 <input type="text" inputmode="decimal" placeholder="-" data-ot="maxB"> 1691 </div> 1692 <div class="iceto-ot-field" data-ot-wrap="maxC"> 1693 <label data-ot-maxl="c">Max stake 2 (optional)</label> 1694 <input type="text" inputmode="decimal" placeholder="-" data-ot="maxC"> 1695 </div> 1696</div> 1697 1698<div class="iceto-ot-kpis"> 1699 <div class="iceto-ot-kpi"><div class="k">Arb %</div><div class="v" data-ot-k="arb">â</div></div> 1700 <div class="iceto-ot-kpi"><div class="k">Profit %</div><div class="v" data-ot-k="profit">â</div></div> 1701 <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">â</div></div> 1702 <div class="iceto-ot-kpi"><div class="k" data-ot="stLabel">Stakes A / B</div><div class="v" data-ot-k="st">â</div></div> 1703</div> 1704 1705<div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 1706 1707<div class="iceto-ot-returnsWrap" data-ot="returnsWrap" style="display:none;"> 1708 <div class="iceto-ot-subhead">Per outcome (after commission + rounding)</div> 1709 <div class="iceto-ot-returns"> 1710 <div class="iceto-ot-returnsHead"> 1711 <div>Outcome</div><div>Stake</div><div>Return</div><div>Profit</div> 1712 </div> 1713 <div class="iceto-ot-returnsRows" data-ot="retRows"></div> 1714 </div> 1715</div> 1716 1717<div class="iceto-ot-note"> 1718 Tip: paste any odds line into any box - we will extract up to 3 odds and fill the fields. 1719 Commission is treated like exchange commission (applied to winnings only). Rounding shows worst-case profit based on rounded stakes. 1720</div> 1721`; 1722 1723 const modeSel = qs('[data-ot="mode"]', rootEl); 1724 const commI = qs('[data-ot="comm"]', rootEl); 1725 const perCommI = qs('[data-ot="perComm"]', rootEl); 1726 const perCommRow = qs('[data-ot="perCommRow"]', rootEl); 1727 const commAI = qs('[data-ot="commA"]', rootEl); 1728 const commBI = qs('[data-ot="commB"]', rootEl); 1729 const commCWrap = qs('[data-ot-wrap="commC"]', rootEl); 1730 const commCI = qs('[data-ot="commC"]', rootEl); 1731 const roundSel = qs('[data-ot="round"]', rootEl); 1732 1733 const aI = qs('[data-ot="a"]', rootEl); 1734 const bI = qs('[data-ot="b"]', rootEl); 1735 const cWrap = qs('[data-ot-wrap="c"]', rootEl); 1736 const cI = qs('[data-ot="c"]', rootEl); 1737 const sI = qs('[data-ot="s"]', rootEl); 1738 1739 const maxTotalI = qs('[data-ot="maxTotal"]', rootEl); 1740 1741 const maxAI = qs('[data-ot="maxA"]', rootEl); 1742 const maxBI = qs('[data-ot="maxB"]', rootEl); 1743 const maxCWrap = qs('[data-ot-wrap="maxC"]', rootEl); 1744 const maxCI = qs('[data-ot="maxC"]', rootEl); 1745 1746 const warnEl = qs('[data-ot="warn"]', rootEl); 1747 const retWrap = qs('[data-ot="returnsWrap"]', rootEl); 1748 const retRows = qs('[data-ot="retRows"]', rootEl); 1749 const stLabel = qs('[data-ot="stLabel"]', rootEl); 1750 1751 const olA = qs('[data-ot-ol="a"]', rootEl); 1752 const olB = qs('[data-ot-ol="b"]', rootEl); 1753 const olC = qs('[data-ot-ol="c"]', rootEl); 1754 1755 const maxLA = qs('[data-ot-maxl="a"]', rootEl); 1756 const maxLB = qs('[data-ot-maxl="b"]', rootEl); 1757 const maxLC = qs('[data-ot-maxl="c"]', rootEl); 1758 1759 const commLA = qs('[data-ot-comml="a"]', rootEl); 1760 const commLB = qs('[data-ot-comml="b"]', rootEl); 1761 const commLC = qs('[data-ot-comml="c"]', rootEl); 1762 1763 const KEY_MODE = 'icetoOddsTools:arbMode'; 1764 const KEY_COMM = 'icetoOddsTools:arbComm'; 1765 const KEY_PERCOMM = 'icetoOddsTools:arbPerComm'; 1766 const KEY_COMMA = 'icetoOddsTools:arbCommA'; 1767 const KEY_COMMB = 'icetoOddsTools:arbCommB'; 1768 const KEY_COMMC = 'icetoOddsTools:arbCommC'; 1769 const KEY_ROUND = 'icetoOddsTools:arbRound'; 1770 const KEY_MAXT = 'icetoOddsTools:arbMaxTotal'; 1771 const KEY_MAXA = 'icetoOddsTools:arbMaxA'; 1772 const KEY_MAXB = 'icetoOddsTools:arbMaxB'; 1773 const KEY_MAXC = 'icetoOddsTools:arbMaxC'; 1774 1775 function setK(key, val){ 1776 const el = qs(`[data-ot-k="${key}"]`, rootEl); 1777 if (el) el.textContent = val; 1778 } 1779 function setWarn(msg){ 1780 if (!warnEl) return; 1781 if (msg){ 1782 warnEl.textContent = msg; 1783 warnEl.style.display = ''; 1784 } else { 1785 warnEl.textContent = ''; 1786 warnEl.style.display = 'none'; 1787 } 1788 } 1789 function setReturns(rows){ 1790 if (!retWrap || !retRows) return; 1791 if (!rows || !rows.length){ 1792 retRows.innerHTML = ''; 1793 retWrap.style.display = 'none'; 1794 return; 1795 } 1796 retWrap.style.display = ''; 1797 retRows.innerHTML = rows.map(r => ` 1798 <div class="iceto-ot-returnsRow"> 1799 <div class="o">${r.outcome}</div> 1800 <div>${r.stake}</div> 1801 <div>${r.ret}</div> 1802 <div class="${r.profitClass}">${r.prof}</div> 1803 </div> 1804 `).join(''); 1805 } 1806 1807 // Floor rounding is safer when book limits exist (never exceeds max limits). 1808 function floorTo(x, step){ 1809 if (!Number.isFinite(x) || !Number.isFinite(step) || step <= 0) return x; 1810 // avoid floating edge cases 1811 return Math.floor((x + 1e-12) / step) * step; 1812 } 1813 1814 function updateMode(){ 1815 const mode = String(modeSel.value || '1x2'); 1816 1817 const perOn = !!(perCommI && perCommI.checked); 1818 if (perCommRow) perCommRow.style.display = perOn ? '' : 'none'; 1819 1820 if (mode === '2way'){ 1821 if (cWrap) cWrap.style.display = 'none'; 1822 if (maxCWrap) maxCWrap.style.display = 'none'; 1823 if (commCWrap) commCWrap.style.display = 'none'; 1824 1825 if (olA) olA.textContent = 'Odds A'; 1826 if (olB) olB.textContent = 'Odds B'; 1827 if (olC) olC.textContent = 'Odds C'; 1828 1829 if (maxLA) maxLA.textContent = 'Max stake A (optional)'; 1830 if (maxLB) maxLB.textContent = 'Max stake B (optional)'; 1831 if (maxLC) maxLC.textContent = 'Max stake C (optional)'; 1832 1833 if (commLA) commLA.textContent = 'Comm A % (optional)'; 1834 if (commLB) commLB.textContent = 'Comm B % (optional)'; 1835 if (commLC) commLC.textContent = 'Comm C % (optional)'; 1836 1837 if (stLabel) stLabel.textContent = 'Stakes A / B'; 1838 } else { 1839 if (cWrap) cWrap.style.display = ''; 1840 if (maxCWrap) maxCWrap.style.display = ''; 1841 if (commCWrap) commCWrap.style.display = ''; 1842 1843 if (olA) olA.textContent = 'Odds 1 (Home)'; 1844 if (olB) olB.textContent = 'Odds X (Draw)'; 1845 if (olC) olC.textContent = 'Odds 2 (Away)'; 1846 1847 if (maxLA) maxLA.textContent = 'Max stake 1 (optional)'; 1848 if (maxLB) maxLB.textContent = 'Max stake X (optional)'; 1849 if (maxLC) maxLC.textContent = 'Max stake 2 (optional)'; 1850 1851 if (commLA) commLA.textContent = 'Comm 1 % (optional)'; 1852 if (commLB) commLB.textContent = 'Comm X % (optional)'; 1853 if (commLC) commLC.textContent = 'Comm 2 % (optional)'; 1854 1855 if (stLabel) stLabel.textContent = 'Stakes 1 / X / 2'; 1856 } 1857 } 1858 1859 // load preferences 1860 try { 1861 if (W.localStorage){ 1862 const lm = localStorage.getItem(KEY_MODE); 1863 const lc = localStorage.getItem(KEY_COMM); 1864 const lr = localStorage.getItem(KEY_ROUND); 1865 const lmt = localStorage.getItem(KEY_MAXT); 1866 const lma = localStorage.getItem(KEY_MAXA); 1867 const lmb = localStorage.getItem(KEY_MAXB); 1868 const lmc = localStorage.getItem(KEY_MAXC); 1869 const lpc = localStorage.getItem(KEY_PERCOMM);
1870 const lca = localStorage.getItem(KEY_COMMA); 1871 const lcb = localStorage.getItem(KEY_COMMB); 1872 const lcc = localStorage.getItem(KEY_COMMC); 1873 if (lm) modeSel.value = lm; 1874 if (lc !== null) commI.value = lc; 1875 if (lr !== null) roundSel.value = lr; 1876 if (lmt !== null) maxTotalI.value = lmt; 1877 if (lma !== null) maxAI.value = lma; 1878 if (lmb !== null) maxBI.value = lmb; 1879 if (lmc !== null && maxCI) maxCI.value = lmc; 1880 if (lpc !== null && perCommI) perCommI.checked = (lpc === '1'); 1881 if (lca !== null && commAI) commAI.value = lca; 1882 if (lcb !== null && commBI) commBI.value = lcb; 1883 if (lcc !== null && commCI) commCI.value = lcc; 1884 } 1885 } catch(e){} 1886 1887 updateMode(); 1888 1889 modeSel.addEventListener('change', ()=>{ 1890 updateMode(); 1891 // per-outcome commission row may change visibility 1892 if (perCommRow) perCommRow.style.display = (perCommI && perCommI.checked) ? '' : 'none'; 1893 calc(); 1894 }); 1895 1896 1897 1898 1899 // Smarter 1X2 extraction: detect labeled odds like "1 1.91 X 3.40 2 4.50" or "Home @ 1.91 Draw 3.40 Away 4.50" 1900 function pickOddsAfterLabel(re, fullText){ 1901 const m = re.exec(fullText); 1902 if (!m) return NaN; 1903 const start = m.index + m[0].length; 1904 const after = fullText.slice(start, start + 80); 1905 const picks = parseOddsFromText(after); 1906 return (picks && picks.length) ? picks[0] : NaN; 1907 } 1908 1909 function extractArbOddsFromPastedText(pastedText, mode){ 1910 const clean = String(pastedText || '').replace(/[\u2212\u2013\u2014]/g, '-'); 1911 const all = parseOddsFromText(clean); 1912 if (!all.length) return null; 1913 1914 if (String(mode) === '2way'){ 1915 return { a: all[0], b: all[1], c: NaN }; 1916 } 1917 1918 const padded = ' ' + clean + ' '; 1919 const o1 = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:1|H|HOME)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded); 1920 const ox = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:X|D|DRAW)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded); 1921 const o2 = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:2|A|AWAY)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded); 1922 1923 let aV = o1, bV = ox, cV = o2; 1924 1925 const gotCount = [aV,bV,cV].filter(v=>Number.isFinite(v)).length; 1926 1927 if (gotCount >= 2){ 1928 // Fill any missing side(s) from remaining odds in the pasted text (preserving order). 1929 function isSame(x,y){ return Math.abs(x-y) < 1e-9; } 1930 const used = [aV,bV,cV].filter(v=>Number.isFinite(v)); 1931 const remaining = all.filter(v => !used.some(u => isSame(u, v))); 1932 1933 if (!Number.isFinite(aV) && remaining.length) aV = remaining.shift(); 1934 if (!Number.isFinite(bV) && remaining.length) bV = remaining.shift(); 1935 if (!Number.isFinite(cV) && remaining.length) cV = remaining.shift(); 1936 1937 return { a: aV, b: bV, c: cV }; 1938 } 1939 1940 // Fallback: first three odds in order. 1941 return { a: all[0], b: all[1], c: all[2] }; 1942 } 1943 1944// Fill Arb odds from a pasted odds line (only on paste) - avoids interfering while typing. 1945 function handleArbOddsPaste(e){ 1946 const el = e.target; 1947 window.setTimeout(()=>{ 1948 const txt = String(el.value || ''); 1949 const mode = String(modeSel.value || '1x2'); 1950 const parsed = extractArbOddsFromPastedText(txt, mode); 1951 if (!parsed) return; 1952 1953 if (Number.isFinite(parsed.a)) aI.value = fmtNum(parsed.a, 2); 1954 if (Number.isFinite(parsed.b)) bI.value = fmtNum(parsed.b, 2); 1955 if (mode !== '2way' && Number.isFinite(parsed.c)) cI.value = fmtNum(parsed.c, 2); 1956 1957 calc(); 1958 }, 0); 1959 } 1960 [aI, bI, cI].forEach(inp => inp.addEventListener('paste', handleArbOddsPaste)); 1961 1962 function roundTo(x, step){ 1963 step = Number(step) || 0; 1964 if (!step) return x; 1965 return Math.round(x / step) * step; 1966 } 1967 1968 function computeMaxStake(mode, invs, sumInv, maxTotal, maxEach, roundStep){ 1969 let maxPossible = Infinity; 1970 let limiter = ''; 1971 1972 if (isFinite(maxTotal) && maxTotal > 0){ 1973 maxPossible = Math.min(maxPossible, maxTotal); 1974 limiter = limiter || 'Max total stake'; 1975 } 1976 1977 // per-book limits 1978 for (let i=0; i<invs.length; i++){ 1979 const lim = maxEach[i]; 1980 if (isFinite(lim) && lim > 0){ 1981 const capT = lim * (sumInv / invs[i]); 1982 if (capT < maxPossible){ 1983 maxPossible = capT; 1984 limiter = (mode === '2way') 1985 ? ('Max stake ' + (i===0 ? 'A' : 'B')) 1986 : ('Max stake ' + (i===0 ? '1' : (i===1 ? 'X' : '2'))); 1987 } 1988 } 1989 } 1990 1991 if (!isFinite(maxPossible) || maxPossible <= 0){ 1992 return {max: 0, limiter: ''}; 1993 } 1994 1995 // align total to rounding step (if any) 1996 if (roundStep > 0){ 1997 // when limits exist, keep it safe (floor). otherwise round to nearest. 1998 const hasAnyLimit = isFinite(maxTotal) || maxEach.some(v => isFinite(v)); 1999 maxPossible = hasAnyLimit ? floorTo(maxPossible, roundStep) : roundTo(maxPossible, roundStep); 2000 } 2001 2002 return {max: maxPossible, limiter}; 2003 } 2004function calc(){ 2005 setWarn(''); 2006 setReturns(null); 2007 2008 const mode = String(modeSel.value || '1x2'); 2009 2010 // Odds fields accept decimal / American (+/-) / fractional. 2011const need = (mode === '2way') ? 2 : 3; 2012const aStr = (aI.value || '').trim(); 2013const bStr = (bI.value || '').trim(); 2014const cStr = (cI.value || '').trim(); 2015let a = oddsToDec(aStr), b = oddsToDec(bStr), c = oddsToDec(cStr), sDesired = num(sI.value); 2016 2017 // Commission as percent (exchange style: applied to winnings only). 2018 let commPct = num(commI.value); 2019 if (!Number.isFinite(commPct)) commPct = 0; 2020 commPct = clamp(commPct, 0, 30); 2021 const comm = commPct / 100; 2022 2023 // Validate required odds for this mode (no silent "partial" calc). 2024const oddsStrs = [aStr, bStr, cStr]; 2025const oddsVals = [a, b, c]; 2026let filled = 0; 2027let invalid = false;
2028 2029for (let i = 0; i < need; i++) 2030{ 2031 if (oddsStrs[i]) { filled++; } 2032 if (oddsStrs[i] && (!Number.isFinite(oddsVals[i]) || oddsVals[i] <= 1.0001)) 2033 { 2034 invalid = true; 2035 } 2036} 2037 2038if (invalid) 2039{ 2040 setWarn('Invalid odds - must be greater than 1.00.'); 2041 setK('arb','â'); setK('profit','â'); setK('ret','â'); setK('st','â'); 2042 return; 2043} 2044 2045if (filled === 0) 2046{ 2047 setK('arb','â'); setK('profit','â'); setK('ret','â'); setK('st','â'); 2048 return; 2049} 2050 2051if (filled < need) 2052{ 2053 setWarn('Enter ' + need + ' odds for this mode.'); 2054 setK('arb','â'); setK('profit','â'); setK('ret','â'); setK('st','â'); 2055 return; 2056} 2057 2058const rawOdds = oddsVals.slice(0, need); 2059 2060 // Effective payout factor with exchange commission: 2061 // net return per unit stake = 1 + (odds-1)*(1-comm) 2062 const factors = rawOdds.map(o => 1 + (o - 1) * (1 - comm)); 2063 const invs = factors.map(f => 1 / f); 2064 const sumInv = invs.reduce((aa,bb)=>aa+bb, 0); 2065 2066 if (!Number.isFinite(sumInv) || sumInv <= 0){ 2067 setK('arb','â'); setK('profit','â'); setK('ret','â'); setK('st','â'); 2068 return; 2069 } 2070 2071 const arb = 1 - sumInv; // positive when arb exists 2072 const totalReturn = 1 / sumInv; // exact (no rounding) return factor 2073 const profitPct = totalReturn - 1; // exact (no rounding) profit factor 2074 2075 setK('arb', (arb*100).toFixed(2) + '%'); 2076 setK('profit', (profitPct*100).toFixed(2) + '%'); 2077 setK('ret', (totalReturn*100).toFixed(2) + '%'); 2078 2079 const lbls = (mode === '1x2' && rawOdds.length === 3) ? ['1','X','2'] : (rawOdds.length === 2 ? ['A','B'] : ['A','B','C']); 2080 2081 // If no stake, still show exact stake ratios. 2082 if (!Number.isFinite(sDesired) || sDesired <= 0){ 2083 const parts = invs.map((inv,i)=>{ 2084 const pct = (inv/sumInv)*100; 2085 return `${lbls[i]} ${pct.toFixed(2)}%`; 2086 }); 2087 setK('st', parts.join(' / ')); 2088 return; 2089 } 2090 2091 // Constraints 2092 let maxTotal = num(maxTotalI.value); 2093 if (!Number.isFinite(maxTotal) || maxTotal <= 0) maxTotal = Infinity; 2094 2095 let maxA = num(maxAI.value); 2096 let maxB = num(maxBI.value); 2097 let maxC = num(maxCI ? maxCI.value : ''); 2098 if (!Number.isFinite(maxA) || maxA <= 0) maxA = Infinity; 2099 if (!Number.isFinite(maxB) || maxB <= 0) maxB = Infinity; 2100 if (!Number.isFinite(maxC) || maxC <= 0) maxC = Infinity; 2101 const maxEachArr = [maxA, maxB]; 2102 if (mode !== '2way' && rawOdds.length === 3) maxEachArr.push(maxC); 2103 2104 2105 const hasLimits = (maxTotal !== Infinity) || maxEachArr.some(l => l !== Infinity); 2106 2107 let sUsed = sDesired; 2108 2109 if (maxTotal !== Infinity){ 2110 sUsed = Math.min(sUsed, maxTotal); 2111 } 2112 // cap total by per-outcome (book) limits based on exact ratios 2113 let capBooks = Infinity; 2114 for (let i=0; i<maxEachArr.length; i++){ 2115 const lim = maxEachArr[i]; 2116 if (lim === Infinity) continue; 2117 const inv = invs[i]; 2118 if (!Number.isFinite(inv) || inv <= 0) continue; 2119 const capT = lim * (sumInv / inv); 2120 capBooks = Math.min(capBooks, capT); 2121 } 2122 if (capBooks !== Infinity){ 2123 sUsed = Math.min(sUsed, capBooks); 2124 } 2125 2126 if (!Number.isFinite(sUsed) || sUsed <= 0){ 2127 setK('st','â'); 2128 setWarn('Stake limits make this staking impossible.'); 2129 return; 2130 } 2131 2132 const stakesExact = invs.map(inv => sUsed * (inv / sumInv)); 2133 const roundStep = num(roundSel.value); 2134 2135 let stakes = stakesExact.slice(); 2136 let totalUsed = sUsed; 2137 let worstProfitPct = profitPct; 2138 2139 if (Number.isFinite(roundStep) && roundStep > 0){ 2140 const step = roundStep; 2141 2142 // Keep total stake aligned to the rounding step (and safe under limits). 2143 const targetTotal = hasLimits ? floorTo(sUsed, step) : roundTo(sUsed, step); 2144 2145 // First pass: round each stake 2146 stakes = stakesExact.map(x => (hasLimits ? floorTo(x, step) : roundTo(x, step))); 2147 totalUsed = stakes.reduce((aa,bb)=>aa + (Number.isFinite(bb) ? bb : 0), 0); 2148 2149 if (totalUsed <= 0){ 2150 setK('st','â'); 2151 return; 2152 } 2153 2154 // Second pass: distribute the leftover in step-sized chunks to hit target total 2155 // while keeping within max book limits and maximizing worst-case profit. 2156 const limitsEach = maxEachArr.slice(0, stakes.length); 2157 2158 const profitsFor = (stArr, tot) => stArr.map((st, i) => st * factors[i] - tot); 2159 2160 const normDiffSteps = Math.round((targetTotal - totalUsed) / step); 2161 let stepsToAdjust = Math.abs(normDiffSteps); 2162 const dir = normDiffSteps >= 0 ? 1 : -1; 2163 2164 // safety: avoid runaway loops with weird inputs 2165 stepsToAdjust = Math.min(stepsToAdjust, 1000); 2166 2167 for (let k = 0; k < stepsToAdjust; k++){ 2168 let bestI = -1; 2169 let bestWorst = -Infinity; 2170 2171 for (let i = 0; i < stakes.length; i++){ 2172 const next = stakes[i] + dir * step; 2173 if (next < 0) continue; 2174 if (dir > 0 && isFinite(limitsEach[i]) && next > limitsEach[i] + 1e-9) continue; 2175 2176 const tmp = stakes.slice(); 2177 tmp[i] = next; 2178 const tmpTotal = totalUsed + dir * step; 2179 2180 const profits = profitsFor(tmp, tmpTotal); 2181 const worst = Math.min.apply(null, profits); 2182 2183 if (worst > bestWorst){ 2184 bestWorst = worst; 2185 bestI = i; 2186 } 2187 } 2188 2189 if (bestI === -1){ 2190 break; 2191 } 2192 2193 stakes[bestI] += dir * step; 2194 totalUsed += dir * step; 2195 } 2196 2197 // update worstProfitPct after rounding distribution (final worst-case recalculated below too) 2198 const profitsNow = profitsFor(stakes, totalUsed); 2199 const worstAbs = Math.min.apply(null, profitsNow); 2200 worstProfitPct = (worstAbs / totalUsed) * 100; 2201 } 2202 2203 // Limit check post-rounding 2204 let over = false;
2205 for (let i=0; i<stakes.length; i++){ 2206 const lim = maxEachArr[i] || Infinity; 2207 if (lim !== Infinity && stakes[i] > lim + 1e-9) over = true; 2208 } 2209 if (over){ 2210 setWarn('Rounded stakes exceed one of your book limits. Try smaller rounding or lower total stake.'); 2211 } 2212 2213 const parts = stakes.map((x,i)=>`${lbls[i]} ${fmtMoney(x)}`); 2214 const usedNote = (Math.abs(totalUsed - sDesired) > 1e-6) ? ` (used: ${fmtMoney(totalUsed)})` : (Number.isFinite(roundStep) && roundStep > 0 ? ` (total: ${fmtMoney(totalUsed)})` : ''); 2215 setK('st', parts.join(' / ') + usedNote); 2216 2217 // Per outcome returns table 2218 const rows = stakes.map((si,i)=>{ 2219 const ret = si * factors[i]; 2220 const prof = ret - totalUsed; 2221 return { 2222 outcome: lbls[i], 2223 stake: fmtMoney(si), 2224 ret: fmtMoney(ret), 2225 prof: fmtMoney(prof), 2226 profitClass: (prof >= 0 ? 'pos' : 'neg') 2227 }; 2228 }); 2229 setReturns(rows); 2230 2231 // Tiny arb warning (use worst-case when rounding) 2232 const tiny = worstProfitPct * 100; 2233 if (tiny > 0 && tiny < 0.20){ 2234 setWarn(`Warning: profit is only ${tiny.toFixed(2)}% after rounding/fees - limits and rounding often wipe this out.`); 2235 } 2236 2237 // persist preferences 2238 try { 2239 if (W.localStorage){ 2240 localStorage.setItem(KEY_MODE, mode); 2241 localStorage.setItem(KEY_COMM, String(commPct)); 2242 localStorage.setItem(KEY_PERCOMM, (perCommI && perCommI.checked) ? '1' : '0'); 2243 localStorage.setItem(KEY_COMMA, String(commAI ? (commAI.value || '') : '')); 2244 localStorage.setItem(KEY_COMMB, String(commBI ? (commBI.value || '') : '')); 2245 localStorage.setItem(KEY_COMMC, String(commCI ? (commCI.value || '') : '')); 2246 localStorage.setItem(KEY_ROUND, String(roundSel.value || '0')); 2247 localStorage.setItem(KEY_MAXT, String(maxTotalI.value || '')); 2248 localStorage.setItem(KEY_MAXA, String(maxAI.value || '')); 2249 localStorage.setItem(KEY_MAXB, String(maxBI.value || '')); 2250 localStorage.setItem(KEY_MAXC, String(maxCI ? (maxCI.value || '') : '')); 2251 } 2252 } catch(e){} 2253 } 2254 2255 [aI,bI,cI,sI,commI,perCommI,commAI,commBI,commCI,roundSel,maxTotalI,maxAI,maxBI,maxCI].filter(Boolean).forEach(el=>{ 2256 el.addEventListener('input', calc); 2257 el.addEventListener('change', calc); 2258 }); 2259 2260 2261 2262setTimeout(calc, 0); 2263 } 2264 2265 2266 2267 function renderParlay(rootEl){ 2268 rootEl.innerHTML = ` 2269 <div class="iceto-ot-field"> 2270 <label>Odds list (2-20)</label> 2271 <textarea rows="3" placeholder="1.91, -110, 10/11, 2.05" data-ot="list"></textarea> 2272 <div class="iceto-ot-chips" data-ot="chips" style="display:none;"></div> 2273 <div class="iceto-ot-note">Paste like: 1.91, 2, 2, 2 - or mixed: 1.91, -110, 10/11, 2.05 - or use x/Ã: 1.91 x 2.05 x 1.80. Decimal commas like 1,91 are supported.</div> 2274 </div> 2275 2276 <div class="iceto-ot-row"> 2277 <div class="iceto-ot-field"> 2278 <label>Stake (optional)</label> 2279 <input type="text" inputmode="decimal" placeholder="100" data-ot="stake"> 2280 </div> 2281 <div class="iceto-ot-field"> 2282 <label>Odds decimals</label> 2283 <select data-ot="dpOdds"> 2284 <option value="2">2 dp</option> 2285 <option value="3">3 dp</option> 2286 <option value="4" selected>4 dp</option> 2287 </select> 2288 </div> 2289 <div class="iceto-ot-field"> 2290 <label>Money decimals</label> 2291 <select data-ot="dpMoney"> 2292 <option value="2" selected>2 dp</option> 2293 <option value="3">3 dp</option> 2294 <option value="4">4 dp</option> 2295 </select> 2296 </div> 2297 </div> 2298 2299 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2300 <div class="iceto-ot-kpis"> 2301 <div class="iceto-ot-kpi"><div class="k">Combined odds</div><div class="v" data-ot-k="odds">â</div></div> 2302 <div class="iceto-ot-kpi"><div class="k">Break-even %</div><div class="v" data-ot-k="be">â</div></div> 2303 <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">â</div></div> 2304 <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">â</div></div> 2305 <div class="iceto-ot-kpi"><div class="k">Profit</div><div class="v" data-ot-k="prof">â</div></div> 2306 </div> 2307 `; 2308 2309 const listTa = qs('[data-ot="list"]', rootEl); 2310 const stakeI = qs('[data-ot="stake"]', rootEl); 2311 const chipsEl = qs('[data-ot="chips"]', rootEl); 2312 const dpOddsSel = qs('[data-ot="dpOdds"]', rootEl); 2313 const dpMoneySel = qs('[data-ot="dpMoney"]', rootEl); 2314 2315 function setK(key, val){ 2316 const el = qs(`[data-ot-k="${key}"]`, rootEl); 2317 if (el) el.textContent = val; 2318 } 2319 2320 function fmtMoneyDp(n, dp){ 2321 if (!Number.isFinite(n)) return 'â'; 2322 return n.toFixed(dp); 2323 } 2324 2325 function renderChips(odds){ 2326 if (!chipsEl) return; 2327 if (!odds || !odds.length){ 2328 chipsEl.innerHTML = ''; 2329 chipsEl.style.display = 'none'; 2330 return; 2331 } 2332 chipsEl.style.display = ''; 2333 chipsEl.innerHTML = odds.map((o,i)=>` 2334 <button type="button" class="iceto-ot-chip" data-idx="${i}">${fmtNum(o,2)} <span class="x">Ã</span></button> 2335 `).join(''); 2336 } 2337 2338 function calc(){ 2339 setInlineWarn(rootEl, ''); 2340 if (hasBadOddsToken(listTa.value || '')){ 2341 setK('odds','â'); setK('be','â'); setK('imp','â'); setK('ret','â'); setK('prof','â'); 2342 setInlineWarn(rootEl, 'Invalid odds in list - odds must be greater than 1.00.'); 2343 return; 2344 } 2345 2346 const odds = parseOddsListFromText(listTa.value || '', 20); 2347 renderChips(odds); 2348 2349 if (odds.length < 2){ 2350 setK('odds','â'); setK('be','â'); setK('imp','â'); setK('ret','â'); setK('prof','â'); 2351 return; 2352 } 2353 2354 let combined = 1;
2355 for (const o of odds) combined *= o; 2356 2357 const dpOdds = clamp(parseInt((dpOddsSel && dpOddsSel.value) || '4', 10) || 4, 2, 4); 2358 const dpMoney = clamp(parseInt((dpMoneySel && dpMoneySel.value) || '2', 10) || 2, 2, 4); 2359 2360 const implied = 1 / combined; // as probability 2361 const bePct = implied * 100; 2362 2363 setK('odds', fmtNum(combined, dpOdds)); 2364 setK('be', bePct.toFixed(2) + '%'); 2365 setK('imp', bePct.toFixed(2) + '% (1 in ' + fmtNum(combined, dpOdds) + ')'); 2366 2367 const stake = num(stakeI.value); 2368 if (isBadStake(stake)){ 2369 setK('ret','â'); 2370 setK('prof','â'); 2371 setInlineWarn(rootEl, 'Stake cannot be negative.'); 2372 return; 2373 } 2374 if (Number.isFinite(stake) && stake > 0){ 2375 const ret = stake * combined; 2376 const prof = ret - stake; 2377 setK('ret', fmtMoneyDp(ret, dpMoney)); 2378 setK('prof', fmtMoneyDp(prof, dpMoney)); 2379 } else { 2380 setK('ret','â'); 2381 setK('prof','â'); 2382 } 2383 } 2384 2385 if (chipsEl) chipsEl.addEventListener('click', (e)=>{ 2386 const btn = e.target.closest('.iceto-ot-chip'); 2387 if (!btn) return; 2388 const idx = parseInt(btn.getAttribute('data-idx')||'',10); 2389 if (!Number.isFinite(idx)) return; 2390 const odds = parseOddsListFromText(listTa.value || '', 20); 2391 if (idx < 0 || idx >= odds.length) return; 2392 odds.splice(idx, 1); 2393 listTa.value = odds.map(o => fmtNum(o,2)).join(', '); 2394 calc(); 2395 }); 2396 2397 listTa.addEventListener('input', calc); 2398 listTa.addEventListener('change', calc); 2399 stakeI.addEventListener('input', calc); 2400 stakeI.addEventListener('change', calc); 2401 if (dpOddsSel) dpOddsSel.addEventListener('change', calc); 2402 if (dpMoneySel) dpMoneySel.addEventListener('change', calc); 2403 2404 setTimeout(calc, 0); 2405 } 2406 2407 2408 function renderKelly(rootEl){ 2409 rootEl.innerHTML = ` 2410 <div class="iceto-ot-row"> 2411 <div class="iceto-ot-field"> 2412 <label>Odds (any format)</label> 2413 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.00, -110, 10/11" data-ot="odds"> 2414 </div> 2415 <div class="iceto-ot-field"> 2416 <label>Your win probability (%)</label> 2417 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="55" data-ot="p"> 2418 </div> 2419 <div class="iceto-ot-field"> 2420 <label>Bankroll</label> 2421 <input type="text" inputmode="decimal" placeholder="1000" data-ot="br"> 2422 </div> 2423 </div> 2424 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2425 <div class="iceto-ot-kpis"> 2426 <div class="iceto-ot-kpi"><div class="k">Kelly fraction</div><div class="v" data-ot-k="k">â</div></div> 2427 <div class="iceto-ot-kpi"><div class="k">Stake (full Kelly)</div><div class="v" data-ot-k="s">â</div></div> 2428 </div> 2429 <div class="iceto-ot-note">Kelly = (b*p - q) / b where b = odds-1, p = win prob, q = 1-p. Use fractions of Kelly in practice.</div> 2430 `; 2431 const oI=qs('[data-ot="odds"]', rootEl), pI=qs('[data-ot="p"]', rootEl), brI=qs('[data-ot="br"]', rootEl); 2432 function calc(){ 2433 setInlineWarn(rootEl, ''); 2434 const odds = oddsToDec(oI.value); 2435 const pRaw = num(pI.value); 2436 const p = pRaw/100; 2437 const br = num(brI.value); 2438 2439 // reset outputs to avoid stale values 2440 qs('[data-ot-k="k"]', rootEl).textContent = 'â'; 2441 qs('[data-ot-k="s"]', rootEl).textContent = 'â'; 2442 2443 if (isBadStake(br)){ 2444 setInlineWarn(rootEl, 'Bankroll cannot be negative.'); 2445 return; 2446 } 2447 if (String(oI.value||'').trim() && Number.isFinite(odds) && odds <= 1){ 2448 setInlineWarn(rootEl, 'Odds must be greater than 1.00.'); 2449 return; 2450 } 2451 if (Number.isFinite(pRaw) && (pRaw < 0 || pRaw > 100)){ 2452 setInlineWarn(rootEl, 'Probability must be between 0 and 100.'); 2453 return; 2454 } 2455 2456 const b = odds - 1; 2457 const q = 1 - p; 2458 if (Number.isFinite(b) && b>0 && Number.isFinite(p) && p>0 && p<1){ 2459 const k = (b*p - q) / b; 2460 const kClamped = clamp(k, 0, 1); 2461 qs('[data-ot-k="k"]', rootEl).textContent = (kClamped*100).toFixed(2) + '%'; 2462 if (Number.isFinite(br) && br>0){ 2463 qs('[data-ot-k="s"]', rootEl).textContent = fmtMoney(br*kClamped); 2464 } 2465 } 2466 }
2467 [oI,pI,brI].forEach(el=>{ 2468 el.addEventListener('input', calc); 2469 el.addEventListener('change', calc); 2470 el.addEventListener('paste', () => setTimeout(calc, 0)); 2471 }); 2472 setTimeout(calc, 0); 2473 } 2474 2475 function renderValue(rootEl){ 2476 rootEl.innerHTML = ` 2477 <div class="iceto-ot-row"> 2478 <div class="iceto-ot-field"> 2479 <label>Odds (any format)</label> 2480 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.00, -110, 10/11" data-ot="odds"> 2481 </div> 2482 <div class="iceto-ot-field"> 2483 <label>Your probability (%)</label> 2484 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="55" data-ot="p"> 2485 </div> 2486 <div class="iceto-ot-field"> 2487 <label>Stake</label> 2488 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s"> 2489 </div> 2490 </div> 2491 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2492 <div class="iceto-ot-kpis"> 2493 <div class="iceto-ot-kpi"><div class="k">Edge vs implied</div><div class="v" data-ot-k="e">â</div></div> 2494 <div class="iceto-ot-kpi"><div class="k">Expected value (EV)</div><div class="v" data-ot-k="ev">â</div></div> 2495 <div class="iceto-ot-kpi"><div class="k">Return if win</div><div class="v" data-ot-k="ret">â</div></div> 2496 <div class="iceto-ot-kpi"><div class="k">Profit if win</div><div class="v" data-ot-k="prof">â</div></div> 2497 </div> 2498 <div class="iceto-ot-note">Edge = your probability - implied probability. EV uses stake*(p*(odds-1) - (1-p)).</div> 2499 `; 2500 const oI=qs('[data-ot="odds"]', rootEl), pI=qs('[data-ot="p"]', rootEl), sI=qs('[data-ot="s"]', rootEl); 2501 const eEl = qs('[data-ot-k="e"]', rootEl); 2502 const evEl = qs('[data-ot-k="ev"]', rootEl); 2503 const retEl = qs('[data-ot-k="ret"]', rootEl); 2504 const profEl = qs('[data-ot-k="prof"]', rootEl); 2505 function calc(){ 2506 setInlineWarn(rootEl, ''); 2507 // reset outputs so stale values don't linger 2508 if (eEl) eEl.textContent = 'â'; 2509 if (evEl) evEl.textContent = 'â'; 2510 retEl.textContent = 'â'; 2511 profEl.textContent = 'â'; 2512 2513 // Odds can be decimal / American (+/-) / fractional. 2514 let odds = oddsToDec(oI.value); 2515 if (!Number.isFinite(odds)){ 2516 const picks = parseOddsFromText(oI.value); 2517 if (picks && picks.length && Number.isFinite(picks[0].value)) odds = picks[0].value; 2518 } 2519 const pRaw = num(pI.value); 2520 const p = pRaw/100; 2521 const stake = num(sI.value); 2522 2523 if (isBadStake(stake)){ 2524 setInlineWarn(rootEl, 'Stake cannot be negative.'); 2525 return; 2526 } 2527 if (String(oI.value||'').trim() && Number.isFinite(odds) && odds <= 1){ 2528 setInlineWarn(rootEl, 'Odds must be greater than 1.00.'); 2529 return; 2530 } 2531 if (Number.isFinite(pRaw) && (pRaw < 0 || pRaw > 100)){ 2532 setInlineWarn(rootEl, 'Probability must be between 0 and 100.'); 2533 return; 2534 } 2535 2536 const imp = decToImp(odds); 2537 if (Number.isFinite(imp) && Number.isFinite(p) && p>0 && p<1){ 2538 if (eEl) eEl.textContent = ((p-imp)*100).toFixed(2) + '%'; 2539 if (Number.isFinite(stake) && stake>0){ 2540 const ev = stake * (p*(odds-1) - (1-p)); 2541 if (evEl) evEl.textContent = fmtMoney(ev); 2542 if (Number.isFinite(stake) && stake > 0 && Number.isFinite(odds) && odds > 1) { 2543 retEl.textContent = fmtMoney(stake * odds); 2544 profEl.textContent = fmtMoney(stake * (odds - 1)); 2545 } else { 2546 retEl.textContent = 'â'; 2547 profEl.textContent = 'â'; 2548 } 2549 } 2550 } 2551 } 2552 2553 // Some browsers auto-fill without firing input - listen to change too. 2554 [oI,pI,sI].forEach(el=>{ 2555 el.addEventListener('input', calc); 2556 el.addEventListener('change', calc); 2557 }); 2558 2559 // Initialize outputs (handles prefilled values, history restore, etc.) 2560 setTimeout(calc, 0); 2561 } 2562 2563 function renderHedge(rootEl){ 2564 rootEl.innerHTML = ` 2565 <div class="iceto-ot-row"> 2566 <div class="iceto-ot-field"> 2567 <label>Initial stake</label> 2568 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s"> 2569 </div> 2570 <div class="iceto-ot-field"> 2571 <label>Initial odds (any format)</label> 2572 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.50" data-ot="o1" data-ot-odds="1"> 2573 </div> 2574 </div> 2575 <div class="iceto-ot-row" style="margin-top:10px;"> 2576 <div class="iceto-ot-field"> 2577 <label>Hedge odds (any format)</label> 2578 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.70" data-ot="o2" data-ot-odds="1"> 2579 </div> 2580 <div class="iceto-ot-field"> 2581 <label>Commission % (optional)</label> 2582 <input type="text" inputmode="decimal" enterkeyhint="done" placeholder="0" data-ot="c"> 2583 </div> 2584 </div> 2585 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2586 <div class="iceto-ot-kpis"> 2587 <div class="iceto-ot-kpi"><div class="k">Hedge stake</div><div class="v" data-ot-k="hs">â</div></div> 2588 <div class="iceto-ot-kpi"><div class="k">Equal profit</div><div class="v" data-ot-k="p">â</div></div> 2589 </div> 2590 <div class="iceto-ot-note iceto-ot-roundNote" data-ot="round" style="display:none;"></div> 2591 <div class="iceto-ot-note">Simple hedge: choose stake so profits are equal regardless of outcome. Commission reduces hedge winnings (optional). Good for cashout comparisons.<
2591/div> 2592 `; 2593 const sI=qs('[data-ot="s"]', rootEl), o1I=qs('[data-ot="o1"]', rootEl), o2I=qs('[data-ot="o2"]', rootEl), cI=qs('[data-ot="c"]', rootEl); 2594 function calc(){ 2595 setInlineWarn(rootEl, ''); 2596 const S = num(sI.value), O1 = oddsToDec(o1I.value), O2 = oddsToDec(o2I.value); 2597 const cRaw = (cI ? String(cI.value || '').trim() : ''); 2598 const cPct = num(cRaw); 2599 if (cRaw && !Number.isFinite(cPct)){ 2600 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2601 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2602 const roundEl = qs('[data-ot="round"]', rootEl); 2603 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2604 setInlineWarn(rootEl, 'Commission must be a number.'); 2605 return; 2606 } 2607 const comm = Number.isFinite(cPct) ? (cPct / 100) : 0; 2608 2609 if (isBadStake(S)){ 2610 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2611 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2612 const roundEl = qs('[data-ot="round"]', rootEl); 2613 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2614 setInlineWarn(rootEl, 'Initial stake cannot be negative.'); 2615 return; 2616 } 2617 2618 if ((String(o1I.value||'').trim() && Number.isFinite(O1) && O1 <= 1) || (String(o2I.value||'').trim() && Number.isFinite(O2) && O2 <= 1)){ 2619 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2620 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2621 const roundEl = qs('[data-ot="round"]', rootEl); 2622 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2623 setInlineWarn(rootEl, 'Odds must be greater than 1.00.'); 2624 return; 2625 } 2626 2627 if (Number.isFinite(S)&&S>0&&Number.isFinite(O1)&&O1>1&&Number.isFinite(O2)&&O2>1){ 2628 // Equalize: profit if original wins = S*(O1-1) - H 2629 // profit if hedge wins = H*(O2-1) - S 2630 // set equal => S*(O1-1) - H = H*(O2-1) - S 2631 // => S*O1 = H*O2 2632 const netO2 = 1 + (O2 - 1) * (1 - comm); 2633 if (Number.isFinite(comm) && comm < 0){ 2634 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2635 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2636 const roundEl = qs('[data-ot="round"]', rootEl); 2637 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2638 setInlineWarn(rootEl, 'Commission cannot be negative.'); 2639 return; 2640 } 2641 if (!Number.isFinite(netO2) || netO2 <= 1){ 2642 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2643 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2644 const roundEl = qs('[data-ot="round"]', rootEl); 2645 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2646 setInlineWarn(rootEl, 'Commission makes hedge odds invali
2646d.'); 2647 return; 2648 } 2649 const H = (S*O1)/netO2; 2650 const P = S*(O1-1) - H; 2651 qs('[data-ot-k="hs"]', rootEl).textContent = fmtMoney(H); 2652 qs('[data-ot-k="p"]', rootEl).textContent = fmtMoney(P); 2653 2654 // Rounding note: if you place a rounded hedge stake, profits won't be perfectly equal. 2655 const roundEl = qs('[data-ot="round"]', rootEl); 2656 if (roundEl){ 2657 const Hr = Math.round(H * 100) / 100; 2658 const p1 = S*(O1-1) - Hr; 2659 const p2 = Hr*(O2-1)*(1-comm) - S; 2660 const diff = p1 - p2; 2661 if (Math.abs(diff) >= 0.01){ 2662 roundEl.style.display = ''; 2663 roundEl.textContent = 'Rounded hedge stake ' + fmtMoney(Hr) + ' - profits ' + fmtMoney(p1) + ' / ' + fmtMoney(p2) + ' (diff ' + fmtMoney(diff) + ').'; 2664 } else { 2665 roundEl.style.display = 'none'; 2666 roundEl.textContent = ''; 2667 } 2668 } 2669 } else { 2670 qs('[data-ot-k="hs"]', rootEl).textContent = 'â'; 2671 qs('[data-ot-k="p"]', rootEl).textContent = 'â'; 2672 const roundEl = qs('[data-ot="round"]', rootEl); 2673 if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; } 2674 } 2675 } 2676 [sI,o1I,o2I,cI].forEach(el=>{ 2677 el.addEventListener('input', calc); 2678 el.addEventListener('change', calc); 2679 el.addEventListener('paste', () => setTimeout(calc, 0)); 2680 }); 2681 setTimeout(calc, 0); 2682 } 2683 2684 function renderDutch(rootEl){ 2685 rootEl.innerHTML = ` 2686 <div class="iceto-ot-row iceto-ot-row--split"> 2687 <div class="iceto-ot-field"> 2688 <label>Odds list (any format) - dutch stake split for equal return</label> 2689 <textarea placeholder="2.20, -110, 10/11" data-ot="odds"></textarea> 2690 <div class="iceto-ot-chips" data-ot="chips" style="display:none;"></div> 2691 <div class="iceto-ot-note">Paste a mix of decimal, American (+/-), and fractional. Decimal commas like 1,91 are supported too.</div> 2692 </div> 2693 <div class="iceto-ot-field"> 2694 <label>Total stake</label> 2695 <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s"> 2696 </div> 2697 </div> 2698 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2699 <div class="iceto-ot-kpis"> 2700 <div class="iceto-ot-kpi"><div class="k">Stake split</div><div class="v" data-ot-k="split">â</div></div> 2701 <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">â</div></div> 2702 <div class="iceto-ot-kpi"><div class="k">Profit</div><div class="v" data-ot-k="prof">â</div></div> 2703 </div> 2704 <div class="iceto-ot-note iceto-ot-roundNote" data-ot="round" style="display:none;"></div> 2705 <div class="iceto-ot-note">Dutching splits stakes across outcomes so the payout is the same whichever wins.</div> 2706 `; 2707 const ta=qs('[data-ot="odds"]', rootEl), sI=qs('[data-ot="s"]', rootEl); 2708 const chipsEl = qs('[data-ot="chips"]', rootEl); 2709 2710 function renderChips(odds){ 2711 if (!chipsEl) return; 2712 if (!odds || !odds.length){ 2713 chipsEl.innerHTML = ''; 2714 chipsEl.style.display = 'none'; 2715 return; 2716 } 2717 chipsEl.style.display = ''; 2718 chipsEl.innerHTML = odds.map((o,i)=> 2719 `<button type="button" class="iceto-ot-chip" data-idx="${i}">${fmtNum(o,2)} <span class="x">Ã</span></button>` 2720 ).join(''); 2721 } 2722 2723 function calc(){ 2724 setInlineWarn(rootEl, ''); 2725 const roundEl0 = qs('[data-ot="round"]', rootEl); 2726 if (roundEl0){ roundEl0.style.display = 'none'; roundEl0.textContent = ''; } 2727 if (hasBadOddsToken(ta.value || '')){ 2728 qs('[data-ot-k="split"]', rootEl).textContent='â'; 2729 qs('[data-ot-k="ret"]', rootEl).textContent='â'; 2730 qs('[data-ot-k="prof"]', rootEl).textContent='â'; 2731 setInlineWarn(rootEl, 'Invalid odds in list - odds must be greater than 1.00.'); 2732 return; 2733 } 2734 2735 const decs = parseOddsListFromText(ta.value, 20); 2736 renderChips(decs); 2737 const S = num(sI.value); 2738 if (isBadStake(S)){ 2739 qs('[data-ot-k="split"]', rootEl).textContent='â'; 2740 qs('[data-ot-k="ret"]', rootEl).textContent='â'; 2741 qs('[data-ot-k="prof"]', rootEl).textContent='â'; 2742 setInlineWarn(rootEl, 'Total stake cannot be negative.'); 2743 return; 2744 } 2745 if (!decs.length || !Number.isFinite(S) || S<=0){ 2746 qs('[data-ot-k="split"]', rootEl).textContent='â'; 2747 qs('[data-ot-k="ret"]', rootEl).textContent='â'; 2748 qs('[data-ot-k="prof"]', rootEl).textContent='â'; 2749 return; 2750 } 2751 const inv = decs.map(d=>1/d); 2752 const sum = inv.reduce((a,b)=>a+b,0); 2753 const ret = S / sum; 2754 const stakes = inv.map(i=>ret*i); 2755 qs('[data-ot-k="split"]', rootEl).textContent = stakes.map(x=>fmtMoney(x)).join(' / '); 2756 qs('[data-ot-k="ret"]', rootEl).textContent = fmtMoney(ret); 2757 qs('[data-ot-k="prof"]', rootEl).textContent = fmtMoney(ret - S); 2758 2759 // Rounding note: if you round each stake to cents, the total can differ slightly. 2760 const roundEl = qs('[data-ot="round"]', rootEl); 2761 if (roundEl){ 2762 const stakesR = stakes.map(x => Math.round(x * 100) / 100); 2763 const sumR = stakesR.reduce((a,b)=>a+b,0); 2764 const diff = sumR - S; 2765 if (Math.abs(diff) >= 0.01){ 2766 roundEl.style.display = ''; 2767 roundEl.textContent = 'Rounded stakes total ' + fmtMoney(sumR) + ' (diff ' + fmtMoney(diff) + ').'; 2768 } else { 2769 roundEl.style.display = 'none'; 2770 roundEl.textContent = ''; 2771 } 2772 } 2773 } 2774 2775 if (chipsEl) chipsEl.addEventListener('click', (e)=>{ 2776 const btn = e.target.closest('.iceto-ot-chip'); 2777 if (!btn) return; 2778 const idx = parseInt(btn.getAttribute('data-idx')||'',10); 2779 if (!Number.isFinite(idx)) return; 2780 const odds = parseOddsListFromText(ta.value || '', 20); 2781 if (idx < 0 || idx >= odds.length) return; 2782 odds.splice(idx, 1); 2783 ta.value = odds.map(o => fmtNum(o,2)).join(', '); 2784 calc(); 2785 }); 2786 2787 ta.addEventListener('input', calc); 2788 ta.addEventListener('change', calc); 2789 sI.addEventListener('input', calc); 2790 sI.addEventListener('change', calc); 2791 2792 setTimeout(calc, 0); 2793 } 2794 2795 function renderLay(rootEl){ 2796 rootEl.innerHTML = ` 2797 <div class="iceto-ot-row"> 2798 <div class="iceto-ot-field"> 2799 <label>Back odds (any format)</label> 2800 <input type="text" inputmode="decimal" placeholder="2.50" data-ot="bo" data-ot-odds="1"> 2801 </div> 2802 <div class="iceto-ot-field"> 2803 <label>Back stake</label> 2804 <input type="text" inputmode="decimal" placeholder="100" data-ot="bs"> 2805 </div> 2806 <div class="iceto-ot-field"> 2807 <label>Lay odds (any format)</label> 2808 <input type="text" inputmode="decimal" placeholder="2.20" data-ot="lo" data-ot-odds="1"> 2809 </div> 2810 <div class="iceto-ot-field"> 2811 <label>Commission % (optional)</label> 2812 <input type="text" inputmode="decimal" placeholder="0" data-ot="c"> 2813 </div> 2814 </div> 2815 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2816 <div class="iceto-ot-kpis"> 2817 <div class="iceto-ot-kpi"><div class="k">Lay stake (equal profit)</div><div class="v" data-ot-k="ls">â</div></div> 2818 <div class="iceto-ot-kpi"><div class="k">Liability</div><div class="v" data-ot-k="liab">â</div></div> 2819 <div class="iceto-ot-kpi"><div class="k">Net profit</div><div class="v" data-ot-k="prof">â</div></div> 2820 </div> 2821 <div class="iceto-ot-note">Exchange commission % is applied to lay winnings. Equal profit uses layStake = backStake * backOdds / (layOdds - commission).</div> 2822 `; 2823 const bo=qs('[data-ot="bo"]', rootEl), bs=qs('[data-ot="bs"]', rootEl), lo=qs('[data-ot="lo"]', rootEl), cI=qs('[data-ot="c"]', rootEl); 2824 function calc(){ 2825 setInlineWarn(rootEl, ''); 2826 const BO=oddsToDec(bo.value), BS=num(bs.value), LO=oddsToDec(lo.value); 2827
2828 if (isBadStake(BS)){ 2829 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2830 qs('[data-ot-k="liab"]', rootEl).textContent = 'â'; 2831 qs('[data-ot-k="prof"]', rootEl).textContent = 'â'; 2832 setInlineWarn(rootEl, 'Back stake cannot be negative.'); 2833 return; 2834 } 2835 2836 if ((String(bo.value||'').trim() && Number.isFinite(BO) && BO <= 1) || (String(lo.value||'').trim() && Number.isFinite(LO) && LO <= 1)){ 2837 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2838 qs('[data-ot-k="liab"]', rootEl).textContent = 'â'; 2839 qs('[data-ot-k="prof"]', rootEl).textContent = 'â'; 2840 setInlineWarn(rootEl, 'Odds must be greater than 1.00.'); 2841 return; 2842 } 2843 2844 const C = (cI ? (num(cI.value) / 100) : 0); 2845 2846 if (Number.isFinite(C) && C < 0){ 2847 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2848 qs('[data-ot-k="liab"]', rootEl).textContent = 'â'; 2849 qs('[data-ot-k="prof"]', rootEl).textContent = 'â'; 2850 setInlineWarn(rootEl, 'Commission cannot be negative.'); 2851 return; 2852 } 2853 2854 if (Number.isFinite(BO)&&BO>1&&Number.isFinite(BS)&&BS>0&&Number.isFinite(LO)&&LO>1){ 2855 const denom = (LO - (Number.isFinite(C) ? C : 0)); 2856 if (denom <= 0){ 2857 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2858 qs('[data-ot-k="liab"]', rootEl).textContent = 'â'; 2859 qs('[data-ot-k="prof"]', rootEl).textContent = 'â'; 2860 setInlineWarn(rootEl, 'Commission too high for these lay odds.'); 2861 return; 2862 } 2863 2864 const LS = (BS*BO)/denom; 2865 const liab = LS*(LO-1); 2866 2867 // Profit if selection loses: lay win (after commission) minus back stake. 2868 const profLose = (LS * (1 - (Number.isFinite(C) ? C : 0))) - BS; 2869 // Profit if selection wins: back profit minus lay liability. 2870 const profWin = (BS*(BO-1)) - liab; 2871 2872 // With the equal-profit formula, profLose ~= profWin (small diffs only from rounding). 2873 const prof = profLose; 2874 2875 qs('[data-ot-k="ls"]', rootEl).textContent = fmtMoney(LS); 2876 qs('[data-ot-k="liab"]', rootEl).textContent = fmtMoney(liab); 2877 qs('[data-ot-k="prof"]', rootEl).textContent = fmtMoney(prof); 2878 } else { 2879 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2880 qs('[data-ot-k="liab"]', rootEl).textContent = 'â'; 2881 qs('[data-ot-k="prof"]', rootEl).textContent = 'â'; 2882 } 2883 } 2884 [bo,bs,lo,cI].filter(Boolean).forEach(el=>{ 2885 el.addEventListener('input', calc); 2886 el.addEventListener('change', calc); 2887 el.addEventListener('paste', () => setTimeout(calc, 0)); 2888 }); 2889 setTimeout(calc, 0); 2890 } 2891 2892 function renderFreebet(rootEl){ 2893 rootEl.innerHTML = ` 2894 <div class="iceto-ot-row"> 2895 <div class="iceto-ot-field"> 2896 <label>Free bet stake</label> 2897 <input type="text" inputmode="decimal" placeholder="50" data-ot="fb"> 2898 </div> 2899 <div class="iceto-ot-field"> 2900 <label>Back odds (any format)</label> 2901 <input type="text" inputmode="decimal" placeholder="3.00" data-ot="bo" data-ot-odds="1"> 2902 </div> 2903 <div class="iceto-ot-field"> 2904 <label>Lay odds (any format)</label> 2905 <input type="text" inputmode="decimal" placeholder="3.10" data-ot="lo" data-ot-odds="1"> 2906 </div> 2907 <div class="iceto-ot-field"> 2908 <label>Commission % (optional)</label> 2909 <input type="text" inputmode="decimal" placeholder="0" data-ot="c"> 2910 </div> 2911 </div> 2912 <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div> 2913 <div class="iceto-ot-kpis"> 2914 <div class="iceto-ot-kpi"><div class="k">Lay stake (SNR)</div><div class="v" data-ot-k="ls">â</div></div> 2915 <div class="iceto-ot-kpi"><div class="k">Estimated cash value</div><div class="v" data-ot-k="cv">â</div></div> 2916 </div> 2917 <div class="iceto-ot-note">SNR free bet: stake is not returned. Commission % is applied to lay winnings. Equal profit uses layStake = freeBetStake * (backOdds-1) / (layOdds - commission).</div> 2918 `; 2919 const fb=qs('[data-ot="fb"]', rootEl), bo=qs('[data-ot="bo"]', rootEl), lo=qs('[data-ot="lo"]', rootEl), cI=qs('[data-ot="c"]', rootEl); 2920 function calc(){ 2921 setInlineWarn(rootEl, '');
2922 const FB=num(fb.value), BO=oddsToDec(bo.value), LO=oddsToDec(lo.value); 2923 2924 if (isBadStake(FB)){ 2925 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2926 qs('[data-ot-k="cv"]', rootEl).textContent = 'â'; 2927 setInlineWarn(rootEl, 'Free bet stake cannot be negative.'); 2928 return; 2929 } 2930 2931 if ((String(bo.value||'').trim() && Number.isFinite(BO) && BO <= 1) || (String(lo.value||'').trim() && Number.isFinite(LO) && LO <= 1)){ 2932 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2933 qs('[data-ot-k="cv"]', rootEl).textContent = 'â'; 2934 setInlineWarn(rootEl, 'Odds must be greater than 1.00.'); 2935 return; 2936 } 2937 2938 const C = (cI ? (num(cI.value) / 100) : 0); 2939 2940 if (Number.isFinite(C) && C < 0){ 2941 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2942 qs('[data-ot-k="cv"]', rootEl).textContent = 'â'; 2943 setInlineWarn(rootEl, 'Commission cannot be negative.'); 2944 return; 2945 } 2946 2947 if (Number.isFinite(FB)&&FB>0&&Number.isFinite(BO)&&BO>1&&Number.isFinite(LO)&&LO>1){ 2948 const denom = (LO - (Number.isFinite(C) ? C : 0)); 2949 if (denom <= 0){ 2950 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2951 qs('[data-ot-k="cv"]', rootEl).textContent = 'â'; 2952 setInlineWarn(rootEl, 'Commission too high for these lay odds.'); 2953 return; 2954 } 2955 2956 const LS = (FB*(BO-1))/denom; 2957 2958 // Guaranteed cash value (equal profit), after commission on lay winnings. 2959 const cv = LS * (1 - (Number.isFinite(C) ? C : 0)); 2960 2961 qs('[data-ot-k="ls"]', rootEl).textContent = fmtMoney(LS); 2962 qs('[data-ot-k="cv"]', rootEl).textContent = fmtMoney(cv); 2963 } else { 2964 qs('[data-ot-k="ls"]', rootEl).textContent = 'â'; 2965 qs('[data-ot-k="cv"]', rootEl).textContent = 'â'; 2966 } 2967 } 2968 [fb,bo,lo,cI].filter(Boolean).forEach(el=>{ 2969 el.addEventListener('input', calc); 2970 el.addEventListener('change', calc); 2971 }); 2972 setTimeout(calc, 0); 2973 } 2974 2975 2976 2977 2978})(); 2979 2980
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.