| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <title>MPTrading Admin</title> |
| <style> |
| :root{ |
| --bg:#0b1020; |
| --panel:#111a33; |
| --panel2:#0f1730; |
| --text:#e7eaf3; |
| --muted:#aab2d5; |
| --line:#233055; |
| --green:#22c55e; |
| --red:#ef4444; |
| --blue:#60a5fa; |
| } |
| *{ box-sizing:border-box; } |
| body{ |
| margin:0; |
| font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; |
| background:var(--bg); |
| color:var(--text); |
| } |
| header{ |
| height:52px; |
| display:flex; |
| align-items:center; |
| justify-content:space-between; |
| padding:0 14px; |
| border-bottom:1px solid var(--line); |
| background:rgba(0,0,0,0.15); |
| gap:12px; |
| } |
| header .title{ |
| font-weight:700; |
| letter-spacing:0.3px; |
| white-space:nowrap; |
| } |
| .small{ font-size:12px; color:var(--muted); } |
| code{ color:#c7d2fe; } |
| main{ |
| padding:12px; |
| display:grid; |
| grid-template-columns: 1.2fr 1fr; |
| gap:12px; |
| height: calc(100vh - 52px); |
| } |
| @media (max-width: 1000px){ |
| main{ grid-template-columns: 1fr; height:auto; } |
| } |
| .card{ |
| background:var(--panel); |
| border:1px solid var(--line); |
| border-radius:10px; |
| overflow:hidden; |
| display:flex; |
| flex-direction:column; |
| min-height:0; |
| } |
| .card h2{ |
| margin:0; |
| padding:10px 12px; |
| font-size:13px; |
| color:var(--muted); |
| text-transform:uppercase; |
| letter-spacing:0.08em; |
| border-bottom:1px solid var(--line); |
| background:var(--panel2); |
| } |
| .content{ |
| padding:12px; |
| overflow:auto; |
| min-height:0; |
| } |
| label{ |
| display:block; |
| font-size:12px; |
| color:var(--muted); |
| margin:10px 0 6px; |
| } |
| input, textarea, button{ |
| font:inherit; |
| border-radius:10px; |
| border:1px solid var(--line); |
| background:#0c1430; |
| color:var(--text); |
| padding:10px 12px; |
| } |
| textarea{ |
| width:100%; |
| min-height:260px; |
| resize:vertical; |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; |
| font-size:12px; |
| line-height:1.35; |
| } |
| input{ width:100%; } |
| .row{ display:grid; grid-template-columns: 1fr 1fr; gap:10px; } |
| .row3{ display:grid; grid-template-columns: 1fr 1fr 1fr; gap:10px; } |
| .actions{ |
| display:flex; |
| gap:10px; |
| flex-wrap:wrap; |
| margin-top:10px; |
| align-items:center; |
| } |
| button{ cursor:pointer; } |
| button.primary:hover{ background:rgba(96,165,250,0.12); } |
| button.danger{ border-color:rgba(239,68,68,0.5); } |
| button.danger:hover{ background:rgba(239,68,68,0.12); } |
| table{ |
| width:100%; |
| border-collapse:collapse; |
| font-size:13px; |
| } |
| th, td{ |
| text-align:left; |
| padding:8px 6px; |
| border-bottom:1px solid rgba(35,48,85,0.6); |
| vertical-align:top; |
| } |
| th{ color:var(--muted); font-weight:600; } |
| td.mono{ |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; |
| } |
| .ok{ color:var(--green); } |
| .err{ color:var(--red); } |
| .hint{ |
| font-size:12px; |
| color:var(--muted); |
| margin-top:6px; |
| line-height:1.35; |
| } |
| .pill{ |
| display:inline-block; |
| padding:2px 8px; |
| border-radius:999px; |
| border:1px solid var(--line); |
| font-size:12px; |
| color:var(--muted); |
| background:rgba(255,255,255,0.02); |
| } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="title">MPTrading Admin</div> |
| <div class="small"> |
| Endpoints: |
| <code>/admin/state</code>, <code>/admin/load_scenario</code>, <code>/admin/add_event</code>, <code>/admin/clear_events</code> |
| </div> |
| </header> |
|
|
| <main> |
| |
| <section class="card"> |
| <h2>Scenario loader</h2> |
| <div class="content"> |
| <label>Admin token (sent as <code>X-ADMIN-TOKEN</code>)</label> |
| <input id="token" type="password" placeholder="Enter ADMIN_TOKEN" autocomplete="off" /> |
|
|
| <div class="row"> |
| <div> |
| <label>Market length override (optional)</label> |
| <input id="marketLength" type="number" min="600" step="1" placeholder="Leave blank = auto" /> |
| <div class="hint"> |
| Blank = auto-size to at least <span class="pill">600</span> and at least the highest scenario day + 1. |
| Set a number to force a specific length (minimum 600). |
| </div> |
| </div> |
| <div> |
| <label>Server state</label> |
| <div class="small"> |
| Day: <span class="mono" id="curDay">--</span><br/> |
| Vol: <span class="mono" id="curVol">--</span><br/> |
| Market length: <span class="mono" id="curMktLen">--</span><br/> |
| Tick rate: <span class="mono" id="curTickRate">--</span> |
| </div> |
| </div> |
| </div> |
|
|
| <label>Scenario JSON</label> |
| <textarea id="scenario" spellcheck="false"></textarea> |
|
|
| <div class="actions"> |
| <button class="primary" id="loadBtn">Load scenario</button> |
| <button class="danger" id="clearBtn">Clear events</button> |
| <button id="refreshBtn">Refresh state</button> |
| <span id="msg" class="small"></span> |
| </div> |
|
|
| <div class="small" style="margin-top:12px;"> |
| Scenario format: |
| <pre class="small" style="white-space:pre-wrap;margin:8px 0 0;color:var(--muted);"> |
| { |
| "name": "FOMC week", |
| "startDay": 0, |
| "basePrice": 100.0, |
| "defaultVolatility": 0.8, |
| "events": [ |
| {"day": 20, "shockPct": 5.0, "volatility": 1.4, "news": "Rumor of rate cut"} |
| ] |
| } |
| </pre> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="card"> |
| <h2>Add event schedule</h2> |
| <div class="content"> |
| <div class="row3"> |
| <div> |
| <label>Offset ticks ahead</label> |
| <input id="offset" type="number" value="10" min="0" step="1" /> |
| </div> |
| <div> |
| <label>Shock % (e.g. 5 or -3)</label> |
| <input id="shockPct" type="number" value="5" step="0.1" /> |
| </div> |
| <div> |
| <label>Volatility (optional)</label> |
| <input id="vol" type="number" placeholder="leave blank" step="0.1" /> |
| </div> |
| </div> |
|
|
| <label>News (optional)</label> |
| <input id="news" type="text" placeholder="Headline to broadcast at that tick" /> |
|
|
| <div class="actions"> |
| <button class="primary" id="addBtn">Add event</button> |
| </div> |
|
|
| <div style="height:12px;"></div> |
|
|
| <table> |
| <thead> |
| <tr> |
| <th>Day</th> |
| <th>Shock</th> |
| <th>Vol</th> |
| <th>News</th> |
| </tr> |
| </thead> |
| <tbody id="eventsBody"> |
| <tr><td colspan="4" class="small">No events loaded.</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </section> |
| </main> |
|
|
| <script> |
| const id = (x) => document.getElementById(x); |
| |
| const token = id("token"); |
| const scenario = id("scenario"); |
| const marketLength = id("marketLength"); |
| |
| const curDay = id("curDay"); |
| const curVol = id("curVol"); |
| const curMktLen = id("curMktLen"); |
| const curTickRate = id("curTickRate"); |
| |
| const msg = id("msg"); |
| const eventsBody = id("eventsBody"); |
| |
| const loadBtn = id("loadBtn"); |
| const clearBtn = id("clearBtn"); |
| const refreshBtn = id("refreshBtn"); |
| const addBtn = id("addBtn"); |
| |
| const offset = id("offset"); |
| const shockPct = id("shockPct"); |
| const vol = id("vol"); |
| const news = id("news"); |
| |
| function setMsg(text, ok = true){ |
| msg.textContent = text; |
| msg.className = ok ? "small ok" : "small err"; |
| } |
| |
| function headers(){ |
| const t = token.value; |
| return { |
| "Content-Type": "application/json", |
| "X-ADMIN-TOKEN": t |
| }; |
| } |
| |
| async function apiGet(path){ |
| const r = await fetch(path, { method: "GET", headers: headers() }); |
| const txt = await r.text(); |
| let data = null; |
| try { data = JSON.parse(txt); } catch { data = { raw: txt }; } |
| if (!r.ok) throw new Error((data && data.detail) ? data.detail : ("HTTP " + r.status)); |
| return data; |
| } |
| |
| async function apiPost(path, body){ |
| const r = await fetch(path, { method: "POST", headers: headers(), body: JSON.stringify(body) }); |
| const txt = await r.text(); |
| let data = null; |
| try { data = JSON.parse(txt); } catch { data = { raw: txt }; } |
| if (!r.ok) throw new Error((data && data.detail) ? data.detail : ("HTTP " + r.status)); |
| return data; |
| } |
| |
| function escapeHtml(s){ |
| return String(s).replace(/[&<>"']/g, (c) => ({ |
| "&":"&","<":"<",">":">",'"':""","'":"'" |
| }[c])); |
| } |
| |
| function renderEvents(events){ |
| const tb = eventsBody; |
| tb.innerHTML = ""; |
| if (!events || !events.length){ |
| tb.innerHTML = '<tr><td colspan="4" class="small">No events loaded.</td></tr>'; |
| return; |
| } |
| for (const e of events){ |
| const tr = document.createElement("tr"); |
| const shock = Number(e.shockPct || 0).toFixed(2); |
| const v = (e.volatility === null || e.volatility === undefined) ? "" : Number(e.volatility).toFixed(2); |
| tr.innerHTML = ` |
| <td class="mono">${Number(e.day)}</td> |
| <td class="mono">${shock}</td> |
| <td class="mono">${v}</td> |
| <td>${escapeHtml(e.news || "")}</td> |
| `; |
| tb.appendChild(tr); |
| } |
| } |
| |
| async function refresh(){ |
| const st = await apiGet("/admin/state"); |
| curDay.textContent = st.day; |
| curVol.textContent = st.currentVolatility; |
| curMktLen.textContent = st.marketLength; |
| curTickRate.textContent = st.tickRate; |
| renderEvents(st.events); |
| } |
| |
| |
| scenario.value = JSON.stringify({ |
| name: "Example scenario", |
| startDay: 0, |
| basePrice: 100.0, |
| defaultVolatility: 0.8, |
| events: [ |
| { day: 20, shockPct: 5.0, volatility: 1.4, news: "Rumor of rate cut" }, |
| { day: 30, shockPct: -3.0, volatility: 2.0, news: "Unexpected hike" } |
| ] |
| }, null, 2); |
| |
| refreshBtn.addEventListener("click", async () => { |
| try{ |
| await refresh(); |
| setMsg("State refreshed."); |
| }catch(e){ |
| setMsg(e.message, false); |
| } |
| }); |
| |
| clearBtn.addEventListener("click", async () => { |
| try{ |
| await apiPost("/admin/clear_events", {}); |
| await refresh(); |
| setMsg("Events cleared."); |
| }catch(e){ |
| setMsg(e.message, false); |
| } |
| }); |
| |
| loadBtn.addEventListener("click", async () => { |
| try{ |
| const obj = JSON.parse(scenario.value); |
| |
| const ml = marketLength.value.trim(); |
| if (ml){ |
| obj.marketLength = Number(ml); |
| } else { |
| delete obj.marketLength; |
| } |
| |
| const res = await apiPost("/admin/load_scenario", obj); |
| await refresh(); |
| setMsg(`Scenario loaded. startDay=${res.startDay}, eventsLoaded=${res.eventsLoaded}, marketLength=${res.marketLength} (${res.marketLengthMode})`); |
| }catch(e){ |
| setMsg(e.message, false); |
| } |
| }); |
| |
| addBtn.addEventListener("click", async () => { |
| try{ |
| const off = Number(offset.value); |
| const sh = Number(shockPct.value); |
| |
| const volRaw = vol.value.trim(); |
| const volatility = volRaw ? Number(volRaw) : null; |
| |
| const n = news.value.trim(); |
| |
| const body = { offset: off, shockPct: sh }; |
| if (volatility !== null && Number.isFinite(volatility)) body.volatility = volatility; |
| if (n) body.news = n; |
| |
| await apiPost("/admin/add_event", body); |
| await refresh(); |
| setMsg("Event added."); |
| }catch(e){ |
| setMsg(e.message, false); |
| } |
| }); |
| |
| |
| refresh().catch(() => {}); |
| </script> |
| </body> |
| </html> |
|
|