index.html (37148B)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>BTC/USD Simulator</title> 7 <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> 8 <style> 9 * { box-sizing: border-box; margin: 0; padding: 0; } 10 11 body { 12 background: #0f0f1a; 13 color: #e0e0e0; 14 font-family: 'Segoe UI', system-ui, sans-serif; 15 height: 100vh; 16 display: flex; 17 flex-direction: column; 18 } 19 20 header { 21 display: flex; 22 align-items: center; 23 justify-content: space-between; 24 padding: 10px 18px; 25 background: #16162a; 26 border-bottom: 1px solid #2a2a4a; 27 flex-shrink: 0; 28 } 29 30 header h1 { 31 font-size: 1rem; 32 font-weight: 600; 33 color: #f0b429; 34 letter-spacing: 0.5px; 35 } 36 37 .header-controls { 38 display: flex; 39 align-items: center; 40 gap: 6px; 41 } 42 43 .period-select { 44 background: #1e1e36; 45 color: #a0a0c0; 46 border: 1px solid #3a3a5a; 47 border-radius: 6px; 48 padding: 5px 8px; 49 font-size: 0.8rem; 50 cursor: pointer; 51 } 52 .period-select:focus { outline: none; border-color: #f0b429; } 53 .period-select option { background: #1e1e36; } 54 55 #new-session-btn { 56 background: #2a2a4a; 57 color: #a0a0c0; 58 border: 1px solid #3a3a5a; 59 border-radius: 6px; 60 padding: 6px 14px; 61 font-size: 0.8rem; 62 cursor: pointer; 63 transition: background 0.15s; 64 } 65 #new-session-btn:hover { background: #3a3a5a; color: #e0e0e0; } 66 67 #help-btn { 68 width: 28px; 69 height: 28px; 70 background: #1e1e36; 71 color: #606080; 72 border: 1px solid #3a3a5a; 73 border-radius: 6px; 74 font-size: 0.85rem; 75 cursor: pointer; 76 display: flex; 77 align-items: center; 78 justify-content: center; 79 flex-shrink: 0; 80 transition: background 0.15s, color 0.15s; 81 } 82 #help-btn:hover { background: #2a2a4a; color: #c0c0e0; } 83 84 #help-overlay { 85 position: fixed; 86 inset: 0; 87 background: rgba(0,0,0,0.6); 88 display: flex; 89 align-items: center; 90 justify-content: center; 91 z-index: 500; 92 padding: 16px; 93 } 94 #help-overlay.hidden { display: none; } 95 96 #help-box { 97 background: #16162a; 98 border: 1px solid #2a2a4a; 99 border-radius: 10px; 100 padding: 24px 28px; 101 max-width: 480px; 102 width: 100%; 103 max-height: 90vh; 104 overflow-y: auto; 105 position: relative; 106 font-size: 0.85rem; 107 line-height: 1.65; 108 color: #c0c0d8; 109 } 110 #help-box h2 { 111 font-size: 0.95rem; 112 font-weight: 600; 113 color: #f0b429; 114 margin-bottom: 8px; 115 } 116 #help-box h2:not(:first-child) { margin-top: 20px; } 117 #help-box p { margin-bottom: 0; } 118 #help-box ol { padding-left: 18px; display: flex; flex-direction: column; gap: 6px; } 119 #help-box .help-note { 120 margin-top: 20px; 121 padding: 10px 14px; 122 background: #1e1e36; 123 border: 1px solid #3a3a5a; 124 border-radius: 6px; 125 font-size: 0.78rem; 126 color: #808098; 127 line-height: 1.6; 128 } 129 #help-close { 130 position: absolute; 131 top: 12px; 132 right: 14px; 133 background: none; 134 border: none; 135 color: #505070; 136 font-size: 1.1rem; 137 cursor: pointer; 138 line-height: 1; 139 padding: 2px 4px; 140 } 141 #help-close:hover { color: #c0c0e0; } 142 143 .pnl-positive { color: #26a69a; } 144 .pnl-negative { color: #ef5350; } 145 .pnl-zero { color: #e0e0e0; } 146 147 .main { 148 display: flex; 149 flex: 1; 150 overflow: hidden; 151 } 152 153 #chart-container { 154 flex: 1; 155 min-width: 0; 156 position: relative; 157 } 158 159 #chart { 160 width: 100%; 161 height: 100%; 162 } 163 164 #loading-overlay { 165 position: absolute; 166 inset: 0; 167 background: #0f0f1a; 168 display: flex; 169 align-items: center; 170 justify-content: center; 171 font-size: 1rem; 172 color: #606080; 173 z-index: 10; 174 } 175 176 .fee-row { 177 display: flex; 178 align-items: center; 179 justify-content: space-between; 180 gap: 8px; 181 } 182 183 .fee-row label { 184 font-size: 0.72rem; 185 color: #808090; 186 white-space: nowrap; 187 } 188 189 .fee-input { 190 width: 68px; 191 background: #1e1e36; 192 border: 1px solid #3a3a5a; 193 border-radius: 4px; 194 color: #e0e0e0; 195 font-size: 0.82rem; 196 padding: 3px 6px; 197 text-align: right; 198 } 199 .fee-input:focus { outline: none; border-color: #f0b429; } 200 201 footer { 202 padding: 10px 18px; 203 background: #16162a; 204 border-top: 1px solid #2a2a4a; 205 display: flex; 206 justify-content: center; 207 align-items: center; 208 gap: 8px; 209 flex-shrink: 0; 210 } 211 212 #next-btn { 213 background: #2a2a6a; 214 color: #a0a0e0; 215 border: 1px solid #4a4a8a; 216 border-radius: 6px; 217 padding: 9px 36px; 218 font-size: 0.9rem; 219 font-weight: 600; 220 cursor: pointer; 221 transition: background 0.15s; 222 } 223 #next-btn:hover:not(:disabled) { background: #3a3a8a; color: #e0e0ff; } 224 #next-btn:disabled { opacity: 0.4; cursor: not-allowed; } 225 226 #auto-play-btn { 227 background: #2a2a6a; 228 color: #a0a0e0; 229 border: 1px solid #4a4a8a; 230 border-radius: 6px; 231 width: 36px; 232 height: 36px; 233 font-size: 1rem; 234 cursor: pointer; 235 transition: background 0.15s, color 0.15s; 236 display: flex; 237 align-items: center; 238 justify-content: center; 239 flex-shrink: 0; 240 } 241 #auto-play-btn:hover:not(:disabled) { background: #3a3a8a; color: #e0e0ff; } 242 #auto-play-btn:disabled { opacity: 0.4; cursor: not-allowed; } 243 #auto-play-btn.playing { background: #3a2a6a; color: #d0a0ff; border-color: #7a4aaa; } 244 245 #ff-to-position-btn { 246 background: #0f2e26; 247 color: #26a69a; 248 border: 1px solid #1a4a3a; 249 border-radius: 6px; 250 padding: 9px 20px; 251 font-size: 0.9rem; 252 font-weight: 600; 253 cursor: pointer; 254 transition: background 0.15s, color 0.15s; 255 } 256 #ff-to-position-btn:hover:not(:disabled) { background: #143d32; color: #4fd4c0; border-color: #267a6a; } 257 #ff-to-position-btn:disabled { opacity: 0.4; cursor: not-allowed; } 258 259 .footer-btn-group { 260 display: flex; 261 flex-direction: column; 262 align-items: center; 263 gap: 2px; 264 } 265 266 .key-hint { 267 font-size: 0.6rem; 268 color: #383858; 269 letter-spacing: 0.5px; 270 } 271 272 /* ── Gym Mode ────────────────────────────────────────────────────────── */ 273 274 275 276 #gym-canvas { 277 position: absolute; 278 inset: 0; 279 width: 100%; 280 height: 100%; 281 pointer-events: none; 282 z-index: 2; 283 } 284 285 #gym-sidebar { 286 display: flex; 287 width: 220px; 288 flex-shrink: 0; 289 background: #16162a; 290 border-left: 1px solid #2a2a4a; 291 flex-direction: column; 292 padding: 16px; 293 gap: 18px; 294 overflow-y: auto; 295 } 296 297 .gym-section { display: flex; flex-direction: column; gap: 8px; } 298 299 .gym-section h2 { 300 font-size: 0.7rem; 301 text-transform: uppercase; 302 letter-spacing: 1px; 303 color: #505070; 304 margin-bottom: 2px; 305 } 306 307 .gym-tool-grid { 308 display: grid; 309 grid-template-columns: 1fr 1fr; 310 gap: 5px; 311 } 312 313 .gym-tool-btn { 314 background: #1e1e36; 315 color: #606080; 316 border: 1px solid #3a3a5a; 317 border-radius: 5px; 318 padding: 7px 4px; 319 font-size: 0.75rem; 320 cursor: pointer; 321 transition: background 0.1s, color 0.1s; 322 text-align: center; 323 } 324 .gym-tool-btn:hover { background: #2a2a4a; color: #a0a0c0; } 325 .gym-tool-btn.active { 326 background: #28105a; 327 color: #c0a0ff; 328 border-color: #5030a0; 329 } 330 331 .gym-hint { 332 font-size: 0.7rem; 333 color: #404060; 334 line-height: 1.45; 335 min-height: 2.2em; 336 } 337 338 #prediction-info { 339 background: #1a1a30; 340 border: 1px solid #3a3a5a; 341 border-radius: 5px; 342 padding: 8px; 343 font-size: 0.72rem; 344 color: #505070; 345 line-height: 1.7; 346 min-height: 64px; 347 font-variant-numeric: tabular-nums; 348 } 349 #prediction-info.has-pred { border-color: #5030a0; color: #c0a0ff; } 350 351 .gym-btn { 352 width: 100%; 353 background: #1e1e36; 354 border: 1px solid #3a3a5a; 355 border-radius: 5px; 356 color: #505070; 357 font-size: 0.75rem; 358 padding: 6px 0; 359 cursor: pointer; 360 transition: background 0.1s, color 0.1s; 361 } 362 .gym-btn:hover { background: #2a2a4a; color: #a0a0c0; } 363 .gym-btn.danger:hover { background: #3a1a1a; color: #ef5350; border-color: #5a2020; } 364 .gym-btn.go { border-color: #1a4a3a; color: #26a69a; } 365 .gym-btn.go:hover:not(:disabled) { background: #0f2e26; color: #4fd4c0; border-color: #267a6a; } 366 .gym-btn:disabled, .gym-btn.go:disabled { opacity: 0.35; cursor: not-allowed; } 367 368 #text-input-overlay { 369 position: fixed; 370 background: #1e1e36; 371 border: 1px solid #5030a0; 372 border-radius: 4px; 373 padding: 3px 7px; 374 color: #e0d8ff; 375 font-size: 0.82rem; 376 z-index: 200; 377 outline: none; 378 min-width: 110px; 379 display: none; 380 } 381 </style> 382 </head> 383 <body> 384 385 <header> 386 <h1>BTC/USD Simulator</h1> 387 <div class="header-controls"> 388 <select id="year-select" class="period-select"> 389 <option value="">Any year</option> 390 <option value="2012">2012</option> 391 <option value="2013">2013</option> 392 <option value="2014">2014</option> 393 <option value="2015">2015</option> 394 <option value="2016">2016</option> 395 <option value="2017">2017</option> 396 <option value="2018">2018</option> 397 <option value="2019">2019</option> 398 <option value="2020">2020</option> 399 <option value="2021">2021</option> 400 <option value="2022">2022</option> 401 <option value="2023">2023</option> 402 <option value="2024">2024</option> 403 <option value="2025">2025</option> 404 <option value="2026">2026</option> 405 </select> 406 <select id="month-select" class="period-select" disabled> 407 <option value="">Any month</option> 408 <option value="1">Jan</option> 409 <option value="2">Feb</option> 410 <option value="3">Mar</option> 411 <option value="4">Apr</option> 412 <option value="5">May</option> 413 <option value="6">Jun</option> 414 <option value="7">Jul</option> 415 <option value="8">Aug</option> 416 <option value="9">Sep</option> 417 <option value="10">Oct</option> 418 <option value="11">Nov</option> 419 <option value="12">Dec</option> 420 </select> 421 <button id="new-session-btn">New Session</button> 422 <button id="help-btn">?</button> 423 </div> 424 </header> 425 426 <div class="main"> 427 <div id="chart-container"> 428 <div id="chart"></div> 429 <canvas id="gym-canvas"></canvas> 430 <div id="loading-overlay">Loading data…</div> 431 </div> 432 433 <div id="gym-sidebar"> 434 <div class="gym-section"> 435 <h2>Drawing Tools</h2> 436 <div class="gym-tool-grid"> 437 <button class="gym-tool-btn" id="tool-line">Line</button> 438 <button class="gym-tool-btn" id="tool-pencil">Pencil</button> 439 <button class="gym-tool-btn" id="tool-text">Text</button> 440 <button class="gym-tool-btn" id="tool-rect">Rectangle</button> 441 </div> 442 <p class="gym-hint" id="tool-hint">Select a tool or pan/zoom freely.</p> 443 </div> 444 445 <div class="gym-section"> 446 <h2>Active Prediction</h2> 447 <div class="fee-row" style="margin-bottom:4px"> 448 <label style="font-size:0.72rem;color:#808090">Fee</label> 449 <span style="display:flex;align-items:center;gap:3px"> 450 <input type="number" id="gym-fee-input" class="fee-input" 451 value="0.10" min="0" max="100" step="0.01"> 452 <span style="font-size:0.78rem;color:#606080">%</span> 453 </span> 454 </div> 455 <div id="prediction-info">No prediction set</div> 456 <button class="gym-btn danger" id="clear-prediction-btn">Cancel prediction</button> 457 </div> 458 459 <div class="gym-section"> 460 <h2>Score</h2> 461 <div id="gym-score" class="pnl-zero" style="font-size:1.1rem;font-weight:700;font-variant-numeric:tabular-nums">$0.00</div> 462 </div> 463 464 <div class="gym-section" style="margin-top:auto"> 465 <button class="gym-btn danger" id="clear-drawings-btn">Clear all drawings</button> 466 </div> 467 </div> 468 </div> 469 470 <input id="text-input-overlay" type="text" placeholder="Type and press Enter"> 471 472 <footer> 473 <div class="footer-btn-group"> 474 <button id="next-btn" disabled>▶▶ Next Candle</button> 475 <span class="key-hint">Space</span> 476 </div> 477 <div class="footer-btn-group"> 478 <button id="auto-play-btn" disabled title="Auto-play (~8 candles/sec)">▶</button> 479 <span class="key-hint"> </span> 480 </div> 481 <div class="footer-btn-group" id="ff-btn-group"> 482 <button id="ff-to-position-btn" disabled>▶▶ Play to close</button> 483 <span class="key-hint">C</span> 484 </div> 485 </footer> 486 487 <div id="help-overlay" class="hidden"> 488 <div id="help-box"> 489 <button id="help-close">✕</button> 490 <h2>What is this?</h2> 491 <p>You are looking at real historic 1 minute BTC/USD pair trading data from Bitstamp. Try predicting how it is going to behave in the next minutes! Some basic tools for visual technical analysis are at your disposal.</p> 492 <h2>How to play?</h2> 493 <ol> 494 <li>Double click somewhere on the right from the last candle to place a prediction. Clicking above the current price creates a long prediction. Create a short prediction by clicking below the current price.</li> 495 <li>Let the time progress till execution by adding candles with the buttons on the bottom.</li> 496 <li>…</li> 497 <li>Profit!! (or not)</li> 498 </ol> 499 <div class="help-note"> 500 <strong style="color:#a0a0c0">Note</strong><br> 501 A long position counts as a positive x1 buy-sell of 100 USD. Equivalent to buying BTC at the current price and selling at the price at the set moment of the prediction. Short is the same but in reverse. 502 </div> 503 </div> 504 </div> 505 506 <script> 507 let sessionId = null; 508 let chart = null; 509 let candleSeries = null; 510 let currentPrice = 0; 511 let gymLastLogical = 0; 512 513 // ── Chart init ──────────────────────────────────────────────────────────── 514 515 function initChart() { 516 const container = document.getElementById('chart'); 517 chart = LightweightCharts.createChart(container, { 518 width: container.clientWidth, 519 height: container.clientHeight, 520 layout: { 521 background: { color: '#0f0f1a' }, 522 textColor: '#a0a0b0', 523 }, 524 grid: { 525 vertLines: { color: '#1e1e36' }, 526 horzLines: { color: '#1e1e36' }, 527 }, 528 crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, 529 rightPriceScale: { borderColor: '#2a2a4a' }, 530 timeScale: { 531 borderColor: '#2a2a4a', 532 timeVisible: true, 533 secondsVisible: false, 534 }, 535 }); 536 537 candleSeries = chart.addCandlestickSeries({ 538 upColor: '#26a69a', 539 downColor: '#ef5350', 540 borderVisible: false, 541 wickUpColor: '#26a69a', 542 wickDownColor: '#ef5350', 543 }); 544 545 window.addEventListener('resize', () => { 546 chart.applyOptions({ 547 width: container.clientWidth, 548 height: container.clientHeight, 549 }); 550 }); 551 } 552 553 // ── Session ─────────────────────────────────────────────────────────────── 554 555 document.getElementById('year-select').addEventListener('change', e => { 556 const monthSel = document.getElementById('month-select'); 557 monthSel.disabled = !e.target.value; 558 if (!e.target.value) monthSel.value = ''; 559 }); 560 561 async function startSession() { 562 stopAutoPlay(); 563 setEnabled(false); 564 document.getElementById('loading-overlay').style.display = 'flex'; 565 gymActivePrediction = null; 566 gymResolvedPredictions.length = 0; 567 gymDrawings.length = 0; 568 gymScore = 0; 569 updatePredictionInfo(); 570 updateGymScore(); 571 572 const year = document.getElementById('year-select').value; 573 const month = document.getElementById('month-select').value; 574 const body = {}; 575 if (year) body.year = parseInt(year, 10); 576 if (month) body.month = parseInt(month, 10); 577 578 try { 579 const res = await fetch('/api/session/new', { 580 method: 'POST', 581 headers: { 'Content-Type': 'application/json' }, 582 body: JSON.stringify(body), 583 }); 584 const data = await res.json(); 585 sessionId = data.session_id; 586 localStorage.setItem('btc_sim_session', sessionId); 587 588 const lwcCandles = data.candles.map(c => ({ 589 time: c.ts, 590 open: c.open, 591 high: c.high, 592 low: c.low, 593 close: c.close, 594 })); 595 candleSeries.setData(lwcCandles); 596 chart.timeScale().fitContent(); 597 gymLastLogical = lwcCandles.length - 1; 598 599 currentPrice = data.candles[data.candles.length - 1].close; 600 setEnabled(true); 601 } catch (err) { 602 document.getElementById('loading-overlay').textContent = 'Failed to connect to server.'; 603 return; 604 } 605 606 document.getElementById('loading-overlay').style.display = 'none'; 607 } 608 609 // ── Helpers ─────────────────────────────────────────────────────────────── 610 611 function fmt(n, decimals = 2) { 612 return n.toLocaleString('en-US', { 613 minimumFractionDigits: decimals, 614 maximumFractionDigits: decimals, 615 }); 616 } 617 618 function setEnabled(enabled) { 619 document.getElementById('next-btn').disabled = !enabled; 620 document.getElementById('auto-play-btn').disabled = !enabled; 621 } 622 623 // ── Next candle ─────────────────────────────────────────────────────────── 624 625 document.getElementById('next-btn').addEventListener('click', async () => { 626 const res = await fetch('/api/next', { 627 method: 'POST', 628 headers: { 'Content-Type': 'application/json' }, 629 body: JSON.stringify({ session_id: sessionId }), 630 }); 631 632 if (res.status === 409) { 633 document.getElementById('next-btn').textContent = 'End of data'; 634 document.getElementById('next-btn').disabled = true; 635 return; 636 } 637 638 const data = await res.json(); 639 const c = data.candle; 640 candleSeries.update({ time: c.ts, open: c.open, high: c.high, low: c.low, close: c.close }); 641 chart.timeScale().scrollToRealTime(); 642 gymLastLogical++; 643 currentPrice = c.close; 644 if (data.resolved) handleResolvedPrediction(data.resolved); 645 else updatePredictionInfo(); 646 }); 647 648 // ── Auto-play ───────────────────────────────────────────────────────────── 649 650 let autoPlayActive = false; 651 let autoPlayTimer = null; 652 653 function stopAutoPlay() { 654 autoPlayActive = false; 655 clearTimeout(autoPlayTimer); 656 autoPlayTimer = null; 657 const btn = document.getElementById('auto-play-btn'); 658 btn.textContent = '►'; 659 btn.title = 'Auto-play (~8 candles/sec)'; 660 btn.classList.remove('playing'); 661 } 662 663 async function autoPlayStep() { 664 if (!autoPlayActive) return; 665 666 const res = await fetch('/api/next', { 667 method: 'POST', 668 headers: { 'Content-Type': 'application/json' }, 669 body: JSON.stringify({ session_id: sessionId }), 670 }); 671 672 if (res.status === 409) { 673 document.getElementById('next-btn').textContent = 'End of data'; 674 document.getElementById('next-btn').disabled = true; 675 stopAutoPlay(); 676 return; 677 } 678 679 const data = await res.json(); 680 const c = data.candle; 681 candleSeries.update({ time: c.ts, open: c.open, high: c.high, low: c.low, close: c.close }); 682 chart.timeScale().scrollToRealTime(); 683 gymLastLogical++; 684 currentPrice = c.close; 685 if (data.resolved) handleResolvedPrediction(data.resolved); 686 else updatePredictionInfo(); 687 688 if (autoPlayActive) { 689 autoPlayTimer = setTimeout(autoPlayStep, 120); 690 } 691 } 692 693 function toggleAutoPlay() { 694 if (autoPlayActive) { 695 stopAutoPlay(); 696 } else { 697 autoPlayActive = true; 698 const btn = document.getElementById('auto-play-btn'); 699 btn.textContent = '■'; 700 btn.title = 'Stop auto-play'; 701 btn.classList.add('playing'); 702 autoPlayStep(); 703 } 704 } 705 706 document.getElementById('auto-play-btn').addEventListener('click', toggleAutoPlay); 707 708 // ── New session button ──────────────────────────────────────────────────── 709 710 document.getElementById('new-session-btn').addEventListener('click', startSession); 711 712 // ── Help overlay ────────────────────────────────────────────────────────── 713 714 const helpOverlay = document.getElementById('help-overlay'); 715 document.getElementById('help-btn').addEventListener('click', () => helpOverlay.classList.remove('hidden')); 716 document.getElementById('help-close').addEventListener('click', () => helpOverlay.classList.add('hidden')); 717 helpOverlay.addEventListener('click', e => { if (e.target === helpOverlay) helpOverlay.classList.add('hidden'); }); 718 document.addEventListener('keydown', e => { if (e.key === 'Escape') helpOverlay.classList.add('hidden'); }); 719 720 // ── Gym Mode ────────────────────────────────────────────────────────────── 721 722 let gymMode = true; 723 let gymFastForwarding = false; 724 let gymActiveTool = null; 725 const gymDrawings = []; 726 let gymActivePrediction = null; // { logTarget, entryPrice, direction } 727 const gymResolvedPredictions = []; 728 let gymScore = 0; 729 let gymIsDrawing = false; 730 let gymCurrentPoints = []; 731 let gymTextPending = null; 732 let gymHoverPos = null; 733 734 const gymCanvas = document.getElementById('gym-canvas'); 735 const gymCtx = gymCanvas.getContext('2d'); 736 const textInputEl = document.getElementById('text-input-overlay'); 737 738 function resizeGymCanvas() { 739 const r = document.getElementById('chart-container').getBoundingClientRect(); 740 gymCanvas.width = r.width; 741 gymCanvas.height = r.height; 742 } 743 744 window.addEventListener('resize', resizeGymCanvas); 745 746 function gymPos(e) { 747 const r = gymCanvas.getBoundingClientRect(); 748 return { x: e.clientX - r.left, y: e.clientY - r.top }; 749 } 750 751 function pxToChart(x, y) { 752 return { 753 logical: chart.timeScale().coordinateToLogical(x), 754 price: candleSeries.coordinateToPrice(y), 755 }; 756 } 757 758 function chartToPx(logical, price) { 759 return { 760 x: chart.timeScale().logicalToCoordinate(logical), 761 y: candleSeries.priceToCoordinate(price), 762 }; 763 } 764 765 gymCanvas.addEventListener('mousedown', e => { 766 if (!gymMode) return; 767 const { x, y } = gymPos(e); 768 const { logical, price } = pxToChart(x, y); 769 if (logical == null || price == null) return; 770 gymIsDrawing = true; 771 gymCurrentPoints = [{ logical, price }]; 772 }); 773 774 gymCanvas.addEventListener('mousemove', e => { 775 if (!gymMode) return; 776 const { x, y } = gymPos(e); 777 const { logical, price } = pxToChart(x, y); 778 gymHoverPos = (logical != null && price != null) ? { logical, price } : null; 779 if (!gymIsDrawing) return; 780 if (logical == null || price == null) return; 781 if (gymActiveTool === 'pencil') { 782 gymCurrentPoints.push({ logical, price }); 783 } else { 784 gymCurrentPoints[1] = { logical, price }; 785 } 786 }); 787 788 gymCanvas.addEventListener('mouseup', e => { 789 if (!gymMode || !gymIsDrawing) return; 790 gymIsDrawing = false; 791 const { x, y } = gymPos(e); 792 const { logical, price } = pxToChart(x, y); 793 794 if (gymActiveTool === 'line' && gymCurrentPoints.length >= 2) { 795 gymDrawings.push({ type: 'line', points: [...gymCurrentPoints] }); 796 } else if (gymActiveTool === 'pencil' && gymCurrentPoints.length >= 2) { 797 gymDrawings.push({ type: 'pencil', points: [...gymCurrentPoints] }); 798 } else if (gymActiveTool === 'text') { 799 if (logical != null && price != null) { 800 gymTextPending = { logical, price }; 801 const cr = gymCanvas.getBoundingClientRect(); 802 textInputEl.style.left = (cr.left + x + 4) + 'px'; 803 textInputEl.style.top = (cr.top + y - 14) + 'px'; 804 textInputEl.style.display = 'block'; 805 textInputEl.value = ''; 806 textInputEl.focus(); 807 } 808 } else if (gymActiveTool === 'rect' && gymCurrentPoints.length >= 2) { 809 gymDrawings.push({ type: 'rect', points: [...gymCurrentPoints] }); 810 } 811 gymCurrentPoints = []; 812 }); 813 814 gymCanvas.addEventListener('mouseleave', () => { 815 gymHoverPos = null; 816 if (gymIsDrawing && gymActiveTool === 'pencil' && gymCurrentPoints.length >= 2) { 817 gymDrawings.push({ type: 'pencil', points: [...gymCurrentPoints] }); 818 } 819 gymIsDrawing = false; 820 gymCurrentPoints = []; 821 }); 822 823 textInputEl.addEventListener('keydown', e => { 824 if (e.key === 'Enter') { 825 const text = textInputEl.value.trim(); 826 if (text && gymTextPending) { 827 gymDrawings.push({ type: 'text', points: [{ ...gymTextPending }], text }); 828 } 829 textInputEl.style.display = 'none'; 830 gymTextPending = null; 831 } else if (e.key === 'Escape') { 832 textInputEl.style.display = 'none'; 833 gymTextPending = null; 834 } 835 }); 836 837 function updatePredictionInfo() { 838 const el = document.getElementById('prediction-info'); 839 const ffBtn = document.getElementById('ff-to-position-btn'); 840 if (!gymActivePrediction) { 841 el.textContent = 'No prediction set'; 842 el.classList.remove('has-pred'); 843 if (ffBtn) ffBtn.disabled = true; 844 return; 845 } 846 const { logTarget, entryPrice, direction } = gymActivePrediction; 847 const barsLeft = Math.max(0, logTarget - gymLastLogical); 848 const dir = direction === 'long' ? '▲ LONG' : '▼ SHORT'; 849 const col = direction === 'long' ? '#26a69a' : '#ef5350'; 850 el.classList.add('has-pred'); 851 el.innerHTML = 852 `<span style="color:${col};font-weight:700">${dir}</span><br>` + 853 `Entry: $${fmt(entryPrice)}<br>` + 854 `Closes: ${barsLeft === 0 ? 'next bar' : '+' + barsLeft + ' bars'}`; 855 if (ffBtn) ffBtn.disabled = gymFastForwarding || barsLeft === 0; 856 } 857 858 function updateGymScore() { 859 const el = document.getElementById('gym-score'); 860 el.textContent = (gymScore >= 0 ? '+' : '') + '$' + fmt(gymScore); 861 el.className = gymScore > 0 ? 'pnl-positive' : gymScore < 0 ? 'pnl-negative' : 'pnl-zero'; 862 el.style.cssText += ';font-size:1.1rem;font-weight:700;font-variant-numeric:tabular-nums'; 863 } 864 865 function handleResolvedPrediction(resolved) { 866 const logTarget = gymActivePrediction ? gymActivePrediction.logTarget : gymLastLogical; 867 if (gymResolvedPredictions.length >= 10) gymResolvedPredictions.shift(); 868 gymResolvedPredictions.push({ 869 logTarget, 870 entryPrice: resolved.entry_price, 871 closePrice: resolved.close_price, 872 direction: resolved.direction, 873 profit: resolved.profit, 874 }); 875 gymScore += resolved.profit; 876 gymActivePrediction = null; 877 updatePredictionInfo(); 878 updateGymScore(); 879 } 880 881 function drawPredictionMarker(ctx, pred, profit) { 882 const { logTarget, entryPrice, direction } = pred; 883 const tx = chart.timeScale().logicalToCoordinate(logTarget); 884 const ey = candleSeries.priceToCoordinate(entryPrice); 885 const col = direction === 'long' ? '#26a69a' : '#ef5350'; 886 const isClosed = profit !== null; 887 888 if (tx == null || ey == null) return; 889 890 // Vertical line at target bar 891 ctx.strokeStyle = col; 892 ctx.lineWidth = 1.5; 893 ctx.setLineDash([5, 4]); 894 ctx.globalAlpha = isClosed ? 0.35 : 0.55; 895 ctx.beginPath(); 896 ctx.moveTo(tx, 0); 897 ctx.lineTo(tx, gymCanvas.height); 898 ctx.stroke(); 899 ctx.setLineDash([]); 900 901 // Horizontal entry-price line 902 ctx.beginPath(); 903 ctx.moveTo(0, ey); 904 ctx.lineTo(gymCanvas.width, ey); 905 ctx.stroke(); 906 ctx.globalAlpha = 1; 907 908 // Close price line (closed predictions only) 909 if (isClosed && pred.closePrice != null) { 910 const cy = candleSeries.priceToCoordinate(pred.closePrice); 911 if (cy != null) { 912 const closedCol = profit >= 0 ? '#26a69a' : '#ef5350'; 913 ctx.strokeStyle = closedCol; 914 ctx.lineWidth = 1; 915 ctx.setLineDash([3, 3]); 916 ctx.globalAlpha = 0.4; 917 ctx.beginPath(); 918 ctx.moveTo(0, cy); 919 ctx.lineTo(gymCanvas.width, cy); 920 ctx.stroke(); 921 ctx.setLineDash([]); 922 ctx.globalAlpha = 1; 923 } 924 } 925 926 // Triangle arrow at entry-line / target intersection 927 const arrowDir = direction === 'long' ? -1 : 1; 928 const aw = 9, ah = 14; 929 ctx.fillStyle = col; 930 ctx.globalAlpha = isClosed ? 0.5 : 1; 931 ctx.beginPath(); 932 ctx.moveTo(tx, ey + arrowDir * ah); 933 ctx.lineTo(tx - aw, ey - arrowDir * 4); 934 ctx.lineTo(tx + aw, ey - arrowDir * 4); 935 ctx.closePath(); 936 ctx.fill(); 937 ctx.globalAlpha = 1; 938 939 // Result badge on closed predictions 940 if (isClosed) { 941 const resultCol = profit >= 0 ? '#26a69a' : '#ef5350'; 942 const resultText = (profit >= 0 ? '+' : '') + '$' + fmt(profit); 943 ctx.font = 'bold 11px "Segoe UI", system-ui, sans-serif'; 944 const tw = ctx.measureText(resultText).width; 945 ctx.fillStyle = 'rgba(15,15,26,0.75)'; 946 ctx.fillRect(tx + 10, ey + 18, tw + 8, 17); 947 ctx.fillStyle = resultCol; 948 ctx.fillText(resultText, tx + 14, ey + 31); 949 } 950 } 951 952 function gymDrawFrame() { 953 requestAnimationFrame(gymDrawFrame); 954 const ctx = gymCtx; 955 ctx.clearRect(0, 0, gymCanvas.width, gymCanvas.height); 956 if (!gymMode) return; 957 958 // Active prediction marker 959 if (gymActivePrediction) { 960 drawPredictionMarker(ctx, gymActivePrediction, null); 961 } 962 963 // Resolved prediction markers 964 for (const cp of gymResolvedPredictions) { 965 drawPredictionMarker(ctx, cp, cp.profit); 966 } 967 968 // In-progress preview 969 if (gymIsDrawing && gymCurrentPoints.length >= 1) { 970 renderGymShape(ctx, gymActiveTool, gymCurrentPoints, null, 0.55); 971 } 972 973 // Saved drawings 974 for (const d of gymDrawings) { 975 renderGymShape(ctx, d.type, d.points, d.text, 1); 976 } 977 } 978 979 function renderGymShape(ctx, type, points, text, alpha) { 980 const px = points.map(p => chartToPx(p.logical, p.price)); 981 if (px.length === 0 || px[0].x == null) return; 982 ctx.globalAlpha = alpha; 983 984 if (type === 'line' || type === 'pencil') { 985 ctx.strokeStyle = type === 'line' ? '#f0c040' : '#88aaff'; 986 ctx.lineWidth = 1.8; 987 ctx.lineJoin = 'round'; 988 ctx.lineCap = 'round'; 989 ctx.beginPath(); 990 ctx.moveTo(px[0].x, px[0].y); 991 for (let i = 1; i < px.length; i++) { 992 if (px[i].x != null && px[i].y != null) ctx.lineTo(px[i].x, px[i].y); 993 } 994 ctx.stroke(); 995 } else if (type === 'rect' && px.length >= 2 && px[1].x != null) { 996 const x0 = Math.min(px[0].x, px[1].x); 997 const y0 = Math.min(px[0].y, px[1].y); 998 const w = Math.abs(px[1].x - px[0].x); 999 const h = Math.abs(px[1].y - px[0].y); 1000 ctx.strokeStyle = '#f0c040'; 1001 ctx.fillStyle = 'rgba(240,192,64,0.07)'; 1002 ctx.lineWidth = 1.5; 1003 ctx.strokeRect(x0, y0, w, h); 1004 ctx.fillRect(x0, y0, w, h); 1005 } else if (type === 'text' && text) { 1006 ctx.fillStyle = '#e0d0ff'; 1007 ctx.font = '13px "Segoe UI", system-ui, sans-serif'; 1008 ctx.fillText(text, px[0].x, px[0].y); 1009 } 1010 1011 ctx.globalAlpha = 1; 1012 } 1013 1014 requestAnimationFrame(gymDrawFrame); 1015 1016 // ── Mode switching ──────────────────────────────────────────────────────── 1017 1018 const GYM_RIGHT_OFFSET = 55; 1019 1020 function setGymTool(tool) { 1021 gymActiveTool = tool; 1022 document.querySelectorAll('.gym-tool-btn').forEach(b => b.classList.remove('active')); 1023 if (tool) { 1024 document.getElementById('tool-' + tool).classList.add('active'); 1025 gymCanvas.style.pointerEvents = 'auto'; 1026 gymCanvas.style.cursor = tool === 'text' ? 'text' : 'crosshair'; 1027 } else { 1028 gymCanvas.style.pointerEvents = 'none'; 1029 gymCanvas.style.cursor = 'default'; 1030 } 1031 const hints = { 1032 line: 'Click and drag to draw a straight line.', 1033 pencil: 'Click and drag to draw freehand.', 1034 text: 'Click to place a text label.', 1035 rect: 'Click and drag to draw a rectangle.', 1036 }; 1037 document.getElementById('tool-hint').textContent = 1038 tool ? hints[tool] : 'Select a tool or pan/zoom freely.'; 1039 } 1040 1041 // Tool buttons — click active tool to deselect 1042 ['line', 'pencil', 'text', 'rect'].forEach(tool => { 1043 document.getElementById('tool-' + tool).addEventListener('click', () => { 1044 setGymTool(gymActiveTool === tool ? null : tool); 1045 }); 1046 }); 1047 1048 document.getElementById('clear-prediction-btn').addEventListener('click', () => { 1049 if (!gymActivePrediction || !sessionId) return; 1050 fetch('/api/predict/cancel', { 1051 method: 'POST', 1052 headers: { 'Content-Type': 'application/json' }, 1053 body: JSON.stringify({ session_id: sessionId }), 1054 }); 1055 gymActivePrediction = null; 1056 updatePredictionInfo(); 1057 }); 1058 1059 async function gymFastForwardToClose() { 1060 if (!gymActivePrediction || gymFastForwarding) return; 1061 stopAutoPlay(); 1062 gymFastForwarding = true; 1063 document.getElementById('ff-to-position-btn').disabled = true; 1064 document.getElementById('next-btn').disabled = true; 1065 document.getElementById('auto-play-btn').disabled = true; 1066 1067 while (gymActivePrediction && gymLastLogical < gymActivePrediction.logTarget) { 1068 const res = await fetch('/api/next', { 1069 method: 'POST', 1070 headers: { 'Content-Type': 'application/json' }, 1071 body: JSON.stringify({ session_id: sessionId }), 1072 }); 1073 1074 if (res.status === 409) { 1075 document.getElementById('next-btn').textContent = 'End of data'; 1076 document.getElementById('next-btn').disabled = true; 1077 break; 1078 } 1079 1080 const data = await res.json(); 1081 const c = data.candle; 1082 candleSeries.update({ time: c.ts, open: c.open, high: c.high, low: c.low, close: c.close }); 1083 chart.timeScale().scrollToRealTime(); 1084 gymLastLogical++; 1085 currentPrice = c.close; 1086 1087 if (data.resolved) { 1088 handleResolvedPrediction(data.resolved); 1089 break; 1090 } else { 1091 updatePredictionInfo(); 1092 } 1093 1094 await new Promise(r => setTimeout(r, 120)); 1095 } 1096 1097 gymFastForwarding = false; 1098 document.getElementById('next-btn').disabled = false; 1099 document.getElementById('auto-play-btn').disabled = false; 1100 updatePredictionInfo(); 1101 } 1102 1103 document.getElementById('ff-to-position-btn').addEventListener('click', gymFastForwardToClose); 1104 1105 document.getElementById('clear-drawings-btn').addEventListener('click', () => { 1106 gymDrawings.length = 0; 1107 }); 1108 1109 // ── Keyboard shortcuts ──────────────────────────────────────────────────── 1110 1111 document.addEventListener('keydown', e => { 1112 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 1113 if (e.key === 'Escape') { 1114 if (gymMode && gymActiveTool !== null) setGymTool(null); 1115 } else if (e.key === ' ') { 1116 e.preventDefault(); 1117 const btn = document.getElementById('next-btn'); 1118 if (!btn.disabled) btn.click(); 1119 } else if (e.key === 'c' || e.key === 'C') { 1120 const btn = document.getElementById('ff-to-position-btn'); 1121 if (!btn.disabled) btn.click(); 1122 } 1123 }); 1124 1125 // ── Double-click prediction (no tool selected) ──────────────────────────── 1126 1127 document.addEventListener('dblclick', e => { 1128 if (!gymMode || gymActiveTool !== null) return; 1129 if (!document.getElementById('chart-container').contains(e.target)) return; 1130 e.stopPropagation(); 1131 e.preventDefault(); 1132 const r = gymCanvas.getBoundingClientRect(); 1133 const x = e.clientX - r.left; 1134 const y = e.clientY - r.top; 1135 const { logical, price } = pxToChart(x, y); 1136 if (logical == null || price == null) return; 1137 const barsAhead = Math.round(logical) - gymLastLogical; 1138 if (barsAhead <= 0) return; 1139 const direction = price > currentPrice ? 'long' : 'short'; 1140 const feePct = parseFloat(document.getElementById('gym-fee-input').value) || 0; 1141 fetch('/api/predict', { 1142 method: 'POST', 1143 headers: { 'Content-Type': 'application/json' }, 1144 body: JSON.stringify({ session_id: sessionId, bars_ahead: barsAhead, direction, fee_pct: feePct }), 1145 }).then(r => r.json()).then(data => { 1146 gymActivePrediction = { 1147 logTarget: gymLastLogical + barsAhead, 1148 entryPrice: data.entry_price, 1149 direction, 1150 }; 1151 updatePredictionInfo(); 1152 }); 1153 }, true); 1154 1155 // ── Boot ────────────────────────────────────────────────────────────────── 1156 1157 initChart(); 1158 chart.timeScale().applyOptions({ rightOffset: GYM_RIGHT_OFFSET }); 1159 resizeGymCanvas(); 1160 setGymTool(null); 1161 startSession(); 1162 </script> 1163 </body> 1164 </html>