File size: 54,929 Bytes
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
4eeb939
 
4d4dd52
4eeb939
7bc086a
e814ba8
4eeb939
 
 
 
 
5e24259
 
4eeb939
 
 
 
 
2a0814c
4eeb939
 
 
 
2a0814c
 
4eeb939
2a0814c
4eeb939
 
2a0814c
 
 
 
 
 
 
 
 
 
 
0a1ea07
 
 
 
 
4eeb939
 
 
 
 
 
 
7bc086a
9a0b677
6f6371c
5e24259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3876fb1
 
9a0b677
 
 
 
 
 
 
 
 
 
 
 
 
0a1ea07
4eeb939
 
7bc086a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4eeb939
2d7510e
 
 
f8c74f2
 
 
 
 
 
2d7510e
 
 
f8c74f2
 
2d7510e
 
 
 
 
 
 
 
 
 
 
f8c74f2
0a1ea07
 
 
 
 
2a0814c
 
 
 
 
 
0a1ea07
 
f8c74f2
 
 
2d7510e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4eeb939
 
 
 
 
 
 
29666da
4eeb939
29666da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c1a974
 
 
29666da
 
 
 
 
 
 
e814ba8
 
fb36c27
 
29666da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c1a974
 
 
 
 
29666da
 
 
5c1a974
 
 
 
 
 
 
 
 
 
 
29666da
4eeb939
 
 
 
 
 
e814ba8
5e24259
4eeb939
 
 
5e24259
 
 
 
 
 
4eeb939
83de142
 
 
 
e814ba8
 
fb36c27
 
6efc5f4
83de142
4eeb939
 
 
 
83de142
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29666da
 
 
 
 
 
 
 
 
 
 
4eeb939
 
 
 
 
 
 
 
988e3ef
 
 
 
 
 
 
 
 
 
 
4eeb939
 
 
 
 
 
 
 
 
 
 
2d7510e
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e24259
7bc086a
c9418e9
 
4eeb939
6c814fb
4eeb939
 
 
 
 
 
81285b7
4eeb939
 
 
 
 
 
 
 
 
 
 
 
83de142
4eeb939
 
 
34158ed
 
 
 
06f8709
 
 
 
 
83de142
34158ed
 
 
39941ec
 
 
 
 
34158ed
39941ec
83de142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4eeb939
e814ba8
fb36c27
4eeb939
e814ba8
4eeb939
 
 
 
 
 
81285b7
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
 
 
 
81285b7
 
 
 
d275eab
81285b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb36c27
81285b7
 
 
 
 
 
 
 
6efc5f4
81285b7
 
 
6c814fb
 
85ef5de
 
81285b7
 
 
 
6c814fb
81285b7
 
 
 
 
 
c7865df
 
 
 
 
 
 
4eeb939
 
 
 
 
 
 
 
 
 
 
2b3b4b1
 
 
 
 
e814ba8
 
 
 
 
7bc086a
 
 
 
 
e814ba8
 
 
d275eab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4eeb939
 
 
 
e814ba8
d275eab
7bc086a
6efc5f4
e814ba8
4eeb939
e814ba8
 
2b3b4b1
4eeb939
 
 
 
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
2bab8b9
 
 
fb36c27
6c814fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb36c27
 
 
 
 
2bab8b9
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
 
 
 
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
/* Portfolio Analyst — input → SSE progress → results summary. No frameworks. */
"use strict";

const $ = (s, el) => (el || document).querySelector(s);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; };
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const pct = (x, signed = true) => (x === null || x === undefined || isNaN(x)) ? "–"
  : ((signed && x > 0 ? "+" : "") + (x * 100).toFixed(1) + "%");
const SECTOR_COLORS = ["#6c8cff", "#2fbf71", "#e8a838", "#e35d6a", "#9b6cff", "#39c2c9",
  "#ff8cc3", "#a3d267", "#c98d5a", "#7f93b8", "#5ad2a8", "#d26767"];

/* ───────── view switching ───────── */
function show(view) {
  for (const v of ["input", "progress", "results"]) $("#view-" + v).hidden = (v !== view);
  window.scrollTo({ top: 0 });
}

/* ───────── input rows ───────── */
const rowsEl = $("#rows");

function addRow(query = "", entry = "", qty = "", bought = "") {
  const tr = el("tr");
  tr.innerHTML = `
    <td><input class="q" placeholder="NVDA or SK Hynix" value="${esc(query)}"></td>
    <td><input class="e" type="number" min="0" step="any" placeholder="e.g. 450" value="${esc(entry)}"></td>
    <td><input class="n" type="number" step="any" placeholder="—" title="Quantity — negative for a sale" value="${esc(qty)}"></td>
    <td><input class="b" type="date" title="Purchase date — unlocks short- vs long-term tax treatment" value="${esc(bought)}"></td>
    <td class="resolved-cell"><div class="resolved"></div></td>
    <td><button class="rm" title="Remove">×</button></td>`;
  tr.querySelector(".rm").onclick = () => { tr.remove(); if (!rowsEl.children.length) addRow(); };
  const q = tr.querySelector(".q"), e = tr.querySelector(".e");
  let timer = null;
  const kick = () => { clearTimeout(timer); delete tr.dataset.split; delete tr.dataset.presplit;
                       timer = setTimeout(() => resolveRow(tr), 500); };
  q.addEventListener("change", kick); e.addEventListener("change", kick);
  rowsEl.appendChild(tr);
  return tr;
}

async function resolveRow(tr, attempt = 0) {
  const q = tr.querySelector(".q").value.trim();
  const entry = parseFloat(tr.querySelector(".e").value);
  const out = tr.querySelector(".resolved");
  if (!q) { out.textContent = ""; return; }
  out.className = "resolved"; out.textContent = attempt ? "retrying…" : "looking up…";
  let r = null;
  try {
    const resp = await fetch("/api/resolve", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: q, entry: isFinite(entry) ? entry : null }),
    });
    if (resp.ok) r = await resp.json();
  } catch { r = null; }
  if (r && !(r.retry && !r.ok)) { applyResolved(tr, r); return; }
  // a transient failure (server waking up, data-source blip): one automatic retry, then a
  // click-to-retry — a dead "lookup failed" on a GOOG row helps nobody
  if (attempt < 1) { setTimeout(() => resolveRow(tr, attempt + 1), 1500); return; }
  out.className = "resolved err retry";
  out.textContent = (r && r.error) ? r.error : "lookup failed (server busy) — click to retry";
  out.title = "click to retry";
  out.onclick = () => { out.onclick = null; resolveRow(tr, 0); };
}

function applyResolved(tr, r) {
  const out = tr.querySelector(".resolved");
  {
    if (!r.ok) { out.className = "resolved err"; out.textContent = r.error; tr.querySelector(".q").classList.add("bad"); return; }
    tr.querySelector(".q").classList.remove("bad");
    tr.dataset.symbol = r.symbol;
    let html = `<b>${esc(r.symbol)}</b> ${esc(r.name || "")} · ${Number(r.price).toFixed(2)} ${esc(r.currency)}`;
    if (r.pl_pct !== undefined)
      html += ` · <span class="${r.pl_pct >= 0 ? "pl-up" : "pl-dn"}">${pct(r.pl_pct)} since entry</span>`;
    out.innerHTML = html;
    markLots();
    for (const w of r.warnings || []) {
      if ((w.code === "presplit_ask" || w.code === "presplit") && w.suggest && w.suggest.field === "entry") {
        // never rewrite the user's numbers: whether the QUANTITY is pre- or post-split only
        // they know (7 shares at 171 vs 70 at 17.13) — ask, with the maths shown
        if (tr.dataset.split === "keep") {
          out.appendChild(el("div", "warn-note", `↕ ${esc(w.message)} Kept as entered (you confirmed it).`));
          continue;
        }
        tr.dataset.presplit = "1";
        const entryNow = parseFloat(tr.querySelector(".e").value), qtyNow = parseFloat(tr.querySelector(".n").value);
        const ratio = entryNow / Number(w.suggest.value);
        const div = el("div", "warn-note", `↕ ${esc(w.message)} Which is right? `);
        const mk = (label, fn) => { const b = el("button", "warn-apply", label); b.onclick = (ev) => { ev.preventDefault(); fn(); }; div.appendChild(b); div.appendChild(document.createTextNode(" ")); };
        mk(`Adjust price & quantity → ${Number(w.suggest.value).toFixed(2)}${isFinite(qtyNow) ? ` × ${Math.round(qtyNow * ratio)} shares` : ""}`, () => {
          tr.dataset.split = "both"; tr.querySelector(".e").value = w.suggest.value;
          if (isFinite(qtyNow)) tr.querySelector(".n").value = Math.round(qtyNow * ratio * 1000) / 1000;
          delete tr.dataset.presplit; resolveRow(tr);
        });
        mk(`Adjust price only → ${Number(w.suggest.value).toFixed(2)} (quantity is already post-split)`, () => {
          tr.dataset.split = "price"; tr.querySelector(".e").value = w.suggest.value; delete tr.dataset.presplit; resolveRow(tr);
        });
        mk(`Keep ${entryNow} as entered`, () => { tr.dataset.split = "keep"; delete tr.dataset.presplit; resolveRow(tr); });
        out.appendChild(div);
        continue;
      }
      const div = el("div", "warn-note", `⚠ ${esc(w.message)} `);
      if (w.suggest) {
        const btn = el("button", "warn-apply", esc(w.suggest.label));
        btn.onclick = (ev) => {
          ev.preventDefault();
          tr.querySelector(w.suggest.field === "entry" ? ".e" : ".q").value = w.suggest.value;
          resolveRow(tr);
        };
        div.appendChild(btn);
        div.appendChild(el("span", "warn-keep", " or keep yours if it's right."));
      }
      out.appendChild(div);
    }
  }
}

/* several rows resolving to one symbol are LOTS of the same holding: show the running
   average so the user sees what the analysis will use (backend does the same maths) */
function markLots() {
  const groups = {};
  for (const tr of rowsEl.children) {
    const sym = tr.dataset.symbol;
    if (sym) (groups[sym] = groups[sym] || []).push(tr);
  }
  for (const tr of rowsEl.children) tr.querySelectorAll(".lot-chip").forEach(x => x.remove());
  for (const trs of Object.values(groups)) {
    if (trs.length < 2) continue;
    const lots = trs.map(tr => ({ e: parseFloat(tr.querySelector(".e").value), n: parseFloat(tr.querySelector(".n").value) }));
    const buys = lots.filter(l => isFinite(l.e) && !(l.n < 0));
    const sized = buys.every(l => l.n > 0);
    const bq = buys.reduce((a, l) => a + (l.n > 0 ? l.n : 0), 0);
    const avg = sized && bq > 0 ? buys.reduce((a, l) => a + l.n * l.e, 0) / bq
                                : buys.reduce((a, l) => a + l.e, 0) / Math.max(buys.length, 1);
    const net = bq - lots.filter(l => l.n < 0).reduce((a, l) => a - l.n, 0);
    trs.forEach((tr, i) => {
      const chip = el("span", "sig lot-chip",
        `lot ${i + 1} of ${trs.length} · avg ${avg.toFixed(2)}${sized ? ` · net ${net}` : " (add quantities to weight it)"}`);
      tr.querySelector(".resolved").appendChild(chip);
    });
  }
}
rowsEl.addEventListener("change", (ev) => { if (ev.target.classList.contains("n") || ev.target.classList.contains("e")) markLots(); });

$("#add-row").onclick = () => addRow();

/* ───────── file import: extract → prefill → user reviews & confirms ───────── */
$("#import-file").onclick = () => $("#file-input").click();
function importProgress(st, label) {
  st.hidden = false;
  st.innerHTML = `<div class="imp-label">${esc(label)}</div>
    <div class="mini-track"><div class="mini-fill"></div></div>`;
}

$("#file-input").addEventListener("change", async (e) => {
  const f = e.target.files && e.target.files[0];
  if (!f) return;
  const st = $("#import-status");
  importProgress(st, `Reading ${f.name} — extracting holdings (aggregating lots)…`);
  const fd = new FormData(); fd.append("file", f);
  let resp;
  try { resp = await fetch("/api/import", { method: "POST", body: fd }); }
  catch { st.textContent = "Upload failed — try again or enter holdings manually."; return; }
  if (!resp.ok) {
    const d = await resp.json().catch(() => ({}));
    st.textContent = d.detail || "Couldn't read that file.";
    return;
  }
  const d = await resp.json();
  rowsEl.innerHTML = "";
  const trs = d.rows.map(r => addRow(r.query, r.entry ?? "", r.qty ?? ""));
  // one server round trip resolves every row in parallel (and pre-warms the analysis data)
  importProgress(st, `Found ${d.rows.length} holdings — verifying live prices for all ${trs.length} at once…`);
  try {
    const br = await fetch("/api/resolve_batch", { method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ rows: d.rows.map(r => ({ query: r.query, entry: r.entry ?? null })) }) }).then(x => x.json());
    if (!Array.isArray(br.results)) throw new Error("no results");
    br.results.forEach((r, i) => {
      if (!trs[i]) return;
      if (r && r.retry && !r.ok) resolveRow(trs[i], 1);   // per-row retry for the transient failures only
      else applyResolved(trs[i], r);
    });
  } catch {
    for (let i = 0; i < trs.length; i += 5) await Promise.all(trs.slice(i, i + 5).map(tr => resolveRow(tr)));
  }
  st.textContent = `Imported ${d.rows.length} holding${d.rows.length > 1 ? "s" : ""} — ` +
    `review each row and fix anything before running.` +
    (d.note ? ` Note: ${d.note}` : "");
  e.target.value = "";
});

/* ───────── global stats ticker ───────── */
(async function ticker() {
  let s;
  try { s = await fetch("/api/stats").then(r => r.json()); } catch { return; }
  const chips = [];
  if (s.n_verdicts) chips.push(`📋 ${s.n_verdicts} verdict${s.n_verdicts > 1 ? "s" : ""} issued${s.since ? " since " + s.since : ""}`);
  if (s.n_cases) chips.push(`👁 ${s.n_cases} portfolio${s.n_cases > 1 ? "s" : ""} on standing watch`);
  if (s.show_perf && s.by_action) {
    for (const [a, v] of Object.entries(s.by_action))
      chips.push(`${a} calls (${v.n}): <b class="${v.avg_return >= 0 ? "up" : "dn"}">${pct(v.avg_return)}</b> since called`);
    if (s.spy_return !== null && s.spy_return !== undefined)
      chips.push(`S&amp;P 500 over the same period: ${pct(s.spy_return)}`);
    chips.push("Every verdict counted — nothing cherry-picked");
  } else {
    chips.push(`🔓 Track record unlocks at ${s.unlock_at} verdicts — every call is being scored`);
  }
  chips.push("🩺 5 analysts · 3 AI models · Damodaran valuation on every profitable holding");
  chips.push("Educational analysis — not investment advice");
  const html = chips.map(c => `<span class="tk">${c}</span>`).join("<span class='tk-sep'>·</span>");
  $("#ticker").innerHTML = html + "<span class='tk-sep'>·</span>" + html;   // duplicated for seamless loop
  $("#ticker-wrap").hidden = false;
})();
$("#load-sample").onclick = async () => {
  const sample = await fetch("/api/sample").then(r => r.json());
  rowsEl.innerHTML = "";
  for (const s of sample.rows) { const tr = addRow(s.query, s.entry, s.qty ?? ""); resolveRow(tr); }
};
for (let i = 0; i < 3; i++) addRow();

/* ───────── verdict ledger: load a tracked portfolio, standing watch ───────── */
let currentRun = null;
let currentCode = null;

function renderWatch(code, data) {
  currentCode = code;
  $("#watch-card").hidden = false;
  $("#watch-code").textContent = code;
  const tr = $("#track-record"); tr.innerHTML = "";
  const t = data.track;
  if (t && t.by_action) {
    let html = `<div class="track-head">Committee track record — ${t.n_verdicts} verdicts since ${esc(t.since)}` +
      (t.spy_return !== null && t.spy_return !== undefined ? ` · S&amp;P 500 ${pct(t.spy_return)} over the same period` : "") + `</div>`;
    for (const [action, v] of Object.entries(t.by_action)) {
      const cls = v.avg_return >= 0 ? "up" : "dn";
      html += `<span class="track-pill">${esc(action)} (${v.n}): <b class="${cls}">${pct(v.avg_return)}</b></span>`;
    }
    tr.innerHTML = html;
  } else {
    tr.innerHTML = `<div class="track-head">Track record starts building from your first saved verdict.</div>`;
  }
  const al = $("#watch-alerts"); al.innerHTML = "";
  const icons = { move: "📉", trend: "⚠️", stale: "⏳", event: "🚨" };
  for (const a of (data.alerts || [])) {
    al.appendChild(el("div", "alert-row",
      `${icons[a.kind] || "•"} <span class="alert-date">${esc(a.date || "")}</span> ${esc(a.msg)}`));
  }
  if (!(data.alerts || []).length)
    al.appendChild(el("div", "alert-row quiet", "No alerts — the watch is quiet."));
}

async function loadCode() {
  const code = $("#code-input").value.trim().toLowerCase();
  const st = $("#code-status"); st.textContent = "";
  if (!code) return;
  st.textContent = "loading…";
  const resp = await fetch("/api/portfolio/" + encodeURIComponent(code));
  if (!resp.ok) { st.textContent = "code not found"; return; }
  const p = await resp.json();
  st.textContent = `case open · ${p.n_runs} verdict${p.n_runs > 1 ? "s" : ""} on record`;
  try { localStorage.setItem("vitals_code", code); } catch {}
  try { history.replaceState(null, "", "?p=" + code); } catch {}
  rowsEl.innerHTML = "";
  for (const r of (p.rows || [])) { const tr = addRow(r.query, r.entry, r.qty ?? ""); resolveRow(tr); }
  if (!rowsEl.children.length) addRow();
  const ctx = p.context || {};
  if (ctx.horizon) $("#ctx-horizon").value = ctx.horizon;
  if (ctx.risk) $("#ctx-risk").value = ctx.risk;
  if (ctx.objective) $("#ctx-objective").value = ctx.objective;
  if (ctx.loss_tolerance) $("#ctx-loss").value = ctx.loss_tolerance;
  if (ctx.liquidity_need) $("#ctx-liq").value = ctx.liquidity_need;
  if (ctx.exclusions && $("#ctx-excl")) $("#ctx-excl").value = ctx.exclusions;
  if (ctx.max_positions && $("#ctx-maxpos")) $("#ctx-maxpos").value = ctx.max_positions;
  if (ctx.horizon || ctx.risk || ctx.objective) $("#ctx-panel").open = true;
  renderWatch(code, p);
}
$("#load-code").onclick = loadCode;
$("#code-input").addEventListener("keydown", e => { if (e.key === "Enter") loadCode(); });

$("#check-now").onclick = async () => {
  if (!currentCode) return;
  const st = $("#check-status"); st.textContent = "checking prices, trends & headlines…";
  const resp = await fetch("/api/check/" + encodeURIComponent(currentCode), { method: "POST" });
  if (!resp.ok) { const d = await resp.json().catch(() => ({})); st.textContent = d.detail || "check failed"; return; }
  renderWatch(currentCode, await resp.json());
  st.textContent = "checked just now";
};

$("#save-track").onclick = async () => {
  if (!currentRun) return;
  const resp = await fetch("/api/save", {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ run_id: currentRun, code: currentCode }),
  });
  const out = $("#save-result"); out.hidden = false;
  if (!resp.ok) { const d = await resp.json().catch(() => ({})); out.textContent = d.detail || "save failed"; return; }
  const d = await resp.json();
  currentCode = d.code;
  out.innerHTML = d.appended
    ? `Verdict added to case <b>${esc(d.code)}</b> — the track record grows.`
    : `Saved. Your case ID is <b class="code-big">${esc(d.code)}</b> — it's remembered on this device, ` +
      `and this page's link now carries it too. Enter it anywhere to reopen your case.`;
  try { localStorage.setItem("vitals_code", d.code); } catch {}
  try { history.replaceState(null, "", "?p=" + d.code); } catch {}
  $("#save-track").hidden = true;
};

/* auto-reopen a case: shareable ?p=code link first, then this device's remembered code */
(function autoLoad() {
  let code = null;
  try { code = new URLSearchParams(location.search).get("p"); } catch {}
  if (!code) { try { code = localStorage.getItem("vitals_code"); } catch {} }
  if (code && /^[a-f0-9]{8}$/.test(code)) {
    $("#code-input").value = code;
    loadCode();
  }
})();

/* ───────── analyze + SSE ───────── */

$("#analyze").onclick = async () => {
  const rows = [...rowsEl.children].map(tr => ({
    query: tr.querySelector(".q").value.trim(),
    entry: parseFloat(tr.querySelector(".e").value) || null,
    qty: parseFloat(tr.querySelector(".n").value) || null,
    bought: (tr.querySelector(".b") && tr.querySelector(".b").value) || null,
    split: tr.dataset.split || null,
  })).filter(r => r.query);
  const errEl = $("#input-error"); errEl.hidden = true;
  if (!rows.length) { errEl.hidden = false; errEl.textContent = "Add at least one holding."; return; }
  const pending = [...rowsEl.children].filter(tr => tr.dataset.presplit === "1").map(tr => tr.querySelector(".q").value.trim());
  if (pending.length) {
    errEl.hidden = false;
    errEl.textContent = `Answer the split question on ${pending.join(", ")} first — the entry price looks pre-split and only you know whether the quantity is.`;
    return;
  }
  if (rows.some(r => !r.entry)) { errEl.hidden = false; errEl.textContent = "Every holding needs an entry price."; return; }
  const context = {
    horizon: $("#ctx-horizon").value || null,
    risk: $("#ctx-risk").value || null,
    objective: $("#ctx-objective").value || null,
    loss_tolerance: $("#ctx-loss").value || null,
    liquidity_need: $("#ctx-liq").value || null,
    exclusions: ($("#ctx-excl") && $("#ctx-excl").value.trim()) || null,
    max_positions: ($("#ctx-maxpos") && $("#ctx-maxpos").value) || null,
    wrapper: ($("#ctx-wrapper") && $("#ctx-wrapper").value) || null,
  };
  let resp;
  try {
    resp = await fetch("/api/analyze", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ rows, context }),
    });
  } catch { errEl.hidden = false; errEl.textContent = "Could not reach the server."; return; }
  if (!resp.ok) {
    const d = await resp.json().catch(() => ({}));
    errEl.hidden = false; errEl.textContent = d.detail || "Could not start the analysis.";
    return;
  }
  const { run_id } = await resp.json();
  currentRun = run_id;
  startProgress(run_id);
};

function startProgress(runId) {
  show("progress");
  $("#bar").style.width = "2%";
  $("#progress-caption").textContent = "Starting the analysis…";
  document.querySelectorAll(".chip").forEach(c => c.classList.remove("lit"));
  const es = new EventSource("/api/events/" + runId);
  es.onmessage = ev => {
    const d = JSON.parse(ev.data);
    if (d.type === "progress") {
      $("#bar").style.width = Math.max(2, d.pct) + "%";
      $("#progress-caption").textContent = d.caption;
      if (d.chip) { const c = document.querySelector(`.chip[data-chip="${d.chip}"]`); if (c) c.classList.add("lit"); }
    } else if (d.type === "done") {
      es.close(); renderResults(d.result);
    } else if (d.type === "error") {
      es.close(); show("input");
      const errEl = $("#input-error"); errEl.hidden = false; errEl.textContent = d.message;
    }
  };
  es.onerror = () => { /* EventSource auto-reconnects; terminal states replay on reconnect */ };
}

/* ───────── results rendering ───────── */
function renderResults(R) {
  show("results");
  $("#save-track").hidden = false;
  $("#save-result").hidden = true;
  if (currentCode) {   // analyzing a tracked portfolio — append this verdict automatically
    fetch("/api/save", { method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ run_id: currentRun, code: currentCode }) })
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (d) { $("#save-track").hidden = true;
        const out = $("#save-result"); out.hidden = false;
        out.innerHTML = `Verdict recorded for portfolio <b>${esc(d.code)}</b>.`; } })
      .catch(() => {});
  }
  // hero gauge
  const score = R.health_score;
  const arc = $("#gauge-arc");
  const len = arc.getTotalLength();
  arc.style.strokeDasharray = len;
  arc.style.strokeDashoffset = len * (1 - score / 100);
  arc.style.stroke = score >= 70 ? "var(--good)" : score >= 45 ? "var(--warn)" : "var(--bad)";
  $("#gauge-num").textContent = score;
  const pil = $("#health-pillars");
  if (pil) {
    const hb = R.health_breakdown || {};
    const rows = hb.pillars || [];
    pil.hidden = !rows.length;
    pil.innerHTML = rows.map(p => {
      const col = p.score >= 65 ? "var(--good)" : p.score >= 45 ? "var(--accent)" : "var(--warn)";
      return `<div class="pillar" title="${esc(p.note || "")}"><span class="pillar-name">${esc(p.label)} <span class="dim">${Math.round(p.weight * 100)}%</span></span>` +
        `<span class="pillar-bar"><span style="width:${Math.max(2, p.score)}%;background:${col}"></span></span><span class="pillar-num">${Math.round(p.score)}</span></div>`;
    }).join("") + (hb.tolerance_penalty ? `<div class="hint">−${Math.abs(hb.tolerance_penalty)} for breaching your stated loss tolerance</div>` : "");
  }
  $("#verdict").textContent = R.verdict;
  // action strip
  const strip = $("#action-strip"); strip.innerHTML = "";
  const order = [["Buy More", "buy"], ["Keep", "keep"], ["Trim", "trim"], ["Sell", "sell"]];
  for (const [a, cls] of order) {
    const n = R.action_counts[a];
    if (n) strip.appendChild(el("span", "pill " + cls, `${n} ${a}`));
  }
  if (R.total_pl_pct !== null && R.total_pl_pct !== undefined)
    strip.appendChild(el("span", "pill pl",
      `Portfolio P/L <span class="${R.total_pl_pct >= 0 ? "up" : "dn"}">${pct(R.total_pl_pct)}</span>`));
  $("#approx-banner").hidden = !(R.metrics && R.metrics.equal_weighted);
  // highlights
  const hl = $("#highlights"); hl.innerHTML = "";
  for (const h of R.highlights) {
    hl.appendChild(el("div", "hl",
      `<span class="dot ${esc(h.severity)}"></span><b>${esc(h.title)}</b>
       <p>${esc(h.detail)}</p><span class="lens">${esc(h.lens || "")} lens</span>`));
  }
  // recommendations table
  const body = $("#rec-body"); body.innerHTML = "";
  const cls = { "Buy More": "buy", "Keep": "keep", "Trim": "trim", "Sell": "sell" };
  for (const r of R.holdings) {
    const tr = el("tr", "main");
    tr.innerHTML = `
      <td class="tick"><b>${esc(r.ticker)}</b><span>${esc(r.name)}</span></td>
      <td>${pct(r.weight, false)}</td>
      <td class="${r.pl_pct >= 0 ? "up" : "dn"}">${pct(r.pl_pct)}${r.entry_note ? ` <span class="sig" title="${esc(r.entry_note)}">${r.entry_note.includes("kept") ? "split: kept as entered" : "split-adjusted"}</span>` : ""}${r.n_lots > 1
        ? ` <span class="sig" title="${esc(r.lot_note || "average cost of " + r.n_lots + " lots")}">${r.n_lots} lots · avg ${Number(r.entry).toFixed(2)}</span>` : ""}</td>
      <td>${r.upside === null || r.upside === undefined
        ? (r.dcf_note ? `<span class="dim" title="DCF not meaningful">${esc(r.dcf_note)}</span>` : "–")
        : `<span class="${r.upside >= 0 ? "up" : "dn"}">${pct(r.upside)}</span>`}</td>
      <td><span class="badge ${cls[r.action]}">${esc(r.action)}</span>${r.watch ? ' <span class="sig" title="weak backing and a negative expected payoff — first source of funds">watch</span>' : ""}</td>
      <td class="why">${esc(r.rationale)}</td>`;
    const dtr = el("tr", "detail"); dtr.hidden = true;
    dtr.innerHTML = `<td colspan="6">${detailHTML(r)}</td>`;
    tr.onclick = () => { dtr.hidden = !dtr.hidden; };
    body.appendChild(tr); body.appendChild(dtr);
  }
  renderPlan(R);
  renderDonut(R);
  renderWeights(R);
  // scorecard
  const sc = $("#scorecard"); sc.innerHTML = "";
  for (const c of R.risk_scorecard)
    sc.appendChild(el("div", "sc",
      `<span class="st ${c.status}">${c.status}</span><div><b>${esc(c.check)}</b><p>${esc(c.detail)}</p></div>`));
  const st = R.strategist || {}; const sr = $("#strategy-read"); sr.innerHTML = "";
  if (st.strategy_read) sr.innerHTML =
    `<b>Strategy read:</b> ${esc(st.strategy_read)}` +
    (st.style_tilt ? ` <b>Style:</b> ${esc(st.style_tilt)}.` : "") +
    (st.gaps && st.gaps.length ? ` <b>Missing exposure:</b> ${esc(st.gaps.join(", "))}.` : "");
  // alternatives (signal tags show which engine surfaced each pick)
  const alts = R.alternatives || [];
  $("#alt-section").hidden = !alts.length;
  const av = $("#alternatives"); av.innerHTML = "";
  const needs = R.portfolio_needs || {};
  const needLabels = Object.values(needs).map(n => n.label).filter(Boolean);
  if (alts.length && needLabels.length)
    av.appendChild(el("div", "needs-line", `<b>What this portfolio needs:</b> ${esc(needLabels.join(" · "))}`));
    for (const g of ((R.plan || {}).gap_search || [])) {
      av.appendChild(el("div", "needs-line dim", `<b>${esc(g.sector)}:</b> ${g.screened} names screened — ` +
        (g.buyable && g.buyable.length ? `buyable today: ${esc(g.buyable.join(", "))}` : "none buyable today") +
        (g.why_not ? ` (${esc(g.why_not)})` : "") + (g.bench && g.bench.length ? `; on the bench with entry conditions: ${esc(g.bench.join(", "))}` : "")));
    }
  for (const a of alts) {
    const tags = (a.signals || []).filter(s => s.includes("smart") || s.includes("quant") || s.includes("local") || s.includes("US-listed")).map(s =>
      `<span class="sig">${esc(s.includes("smart") ? "🐋 smart money" : s.includes("quant") ? "🧮 quant rank" : s.includes("local") ? "📍 local market" : "🇺🇸 US-listed")}</span>`).join(" ");
    const fit = a.fit || {};
    const fixes = (fit.fixes || []).map(f => `<span class="fix">✓ ${esc(f)}</span>`).join(" ") +
      (fit.cautions || []).map(f => `<span class="caution">⚠ ${esc(f)}</span>`).join(" ");
    const com = a.committee;
    const comLine = com ? `<div class="committee">Committee ${com.conviction}/100 on adding it` +
      ((com.risks || []).length ? ` · top risk: ${esc(com.risks[0])}` : "") + `</div>` : "";
    av.appendChild(el("div", "alt", `<b>${esc(a.ticker)}</b> ${tags}<p>${esc(a.reason)}</p>` +
      (fixes ? `<div class="fixes">${fixes}</div>` : "") + comLine));
  }
  // recommended target portfolio (only when the user gave context)
  const target = R.target_portfolio || [];
  $("#target-section").hidden = !target.length;
  if (target.length) {
    const ctx = R.context || {};
    $("#target-note").textContent =
      `Built for: ${[ctx.horizon, ctx.risk, ctx.objective].filter(Boolean).join(" · ")} — ` +
      `committee conviction sizes positions under risk-profile caps. Educational illustration, not advice.`;
    const tb = $("#target-body"); tb.innerHTML = "";
    for (const r of target) {
      const d = r.delta;
      const dCls = d > 0.005 ? "up" : d < -0.005 ? "dn" : "";
      const tr = el("tr");
      tr.innerHTML = `
        <td class="tick"><b>${esc(r.ticker)}</b>${r.is_new ? ' <span class="badge buy">New</span>' : ""}</td>
        <td class="why">${esc(r.sector)}</td>
        <td>${pct(r.current_w, false)}</td>
        <td><b>${pct(r.target_w, false)}</b></td>
        <td class="${dCls}">${d > 0 ? "▲" : d < 0 ? "▼" : "·"} ${pct(Math.abs(d), false)}</td>`;
      tb.appendChild(tr);
    }
  }
  // footer
  renderRisk(R);
  renderRejected(R);
  $("#download").href = "/api/report/" + currentRun;
  $("#audit").href = "/api/audit/" + currentRun;
  $("#meta").textContent = `Generated ${R.generated_at}` +
    (R.metrics.equal_weighted ? " · equal-weighted (no quantities given)" : "") +
    (R.llm_used ? "" : " · quantitative view only — no AI key configured");
  $("#disclaimer").textContent = R.disclaimer;
}

function renderPlan(R) {
  const plan = R.plan;
  $("#plan-section").hidden = !plan;
  if (!plan) return;
  const revival = plan.mode === "revival";
  $("#plan-title").textContent = revival ? "🚑 Revival plan" : "⚡ Enhancement plan";
  $("#plan-pitch").textContent = plan.pitch || "";
  const mv = $("#plan-moves"); mv.innerHTML = "";
  (plan.moves || []).forEach((m, i) => {
    mv.appendChild(el("div", "move",
      `<span class="move-n">${i + 1}</span><div><b>${esc(m.title)}</b><p>${esc(m.detail)}</p></div>`));
  });
  const extraBox = $("#plan-extra"); if (extraBox) {
    let x = "";
    if (plan.do_nothing) x += `<div class="move"><span class="move-n">✓</span><div><b>Do nothing</b><p>Nothing clears the hurdle for a trade. Buffett's punch card: inactivity is a decision, and here it is the right one. Re-check when a sell trigger fires.</p></div></div>`;
    for (const h of (plan.harvest_pairs || [])) {
      x += `<div class="move"><span class="move-n">$</span><div><b>Tax-loss harvest: sell ${esc(h.sell)}, buy ${esc(h.buy)}</b><p>${esc(h.note)} <span class="dim">(${esc(h.buy_name)}, correlation ${h.correlation})</span></p></div></div>`;
    }
    const pm = R.premortem;
    if (pm && pm.story) x += `<div class="move premortem"><span class="move-n">⚠</span><div><b>Pre-mortem — how ${esc(pm.ticker)} loses 40% from here</b><p>${esc(pm.story)}</p></div></div>`;
    extraBox.innerHTML = x; extraBox.hidden = !x;
  }
  // switch pairs
  const pp = $("#plan-pairs"); pp.innerHTML = "";
  for (const p of (plan.switch_pairs || [])) {
    const cmp = p.compare || {}; const o = cmp.out || {}, n = cmp.in || {};
    const washNote = p.tax_note ? ` <span class="sig" title="${esc(p.tax_note)}">wash-sale rule</span>` : "";
    const line = (s) => [
      s.pe !== null && s.pe !== undefined ? `P/E ${Number(s.pe).toFixed(0)}` : null,
      s.rev_growth !== null && s.rev_growth !== undefined ? `growth ${pct(s.rev_growth)}` : null,
      s.vs_200d !== null && s.vs_200d !== undefined ? `${pct(s.vs_200d)} vs 200-day` : null,
    ].filter(Boolean).join(" · ") || "—";
    pp.appendChild(el("div", "pair",
      `<div class="pair-head"><span class="badge sell">${esc(p.out)}</span> ➜ ` +
      `<span class="badge buy">${esc(p.in)}</span> <span class="pair-sec">${esc(p.sector || "")}` +
      `${p.same_sector ? " · same sector" : ""}</span></div>` +
      `<div class="pair-cmp"><span class="dn">${esc(p.out)}: ${esc(line(o))}</span>` +
      `<span class="up">${esc(p.in)}: ${esc(line(n))}</span></div>`));
  }
  // before/after outlook
  const ol = plan.outlook || {}; const cur = ol.current || {}, pl = ol.planned || {};
  const cell = (lab, a, b, fmt) => {
    const f = fmt || (x => x === null || x === undefined ? "–" : x);
    return `<div class="ol"><span class="ol-lab">${lab}</span><span>${f(a)} <span class="ol-arrow">➜</span> <b>${f(b)}</b></span></div>`;
  };
  const outEl = $("#plan-outlook");
  outEl.innerHTML =
    cell("Est. health", cur.est_health, pl.est_health) +
    cell("Market sensitivity (beta)", cur.beta, pl.beta) +
    cell("Biggest sector", cur.top_sector ? `${cur.top_sector[0]} ${pct(cur.top_sector[1], false)}` : "–",
         pl.top_sector ? `${pl.top_sector[0]} ${pct(pl.top_sector[1], false)}` : "–") +
    ((cur.expected_excess_12m !== null && cur.expected_excess_12m !== undefined &&
      pl.expected_excess_12m !== null && pl.expected_excess_12m !== undefined)
      ? cell("Model 12-mo edge vs market", pct(cur.expected_excess_12m), pct(pl.expected_excess_12m))
      : "");
  renderConstruction(plan);
  // orders
  const ob = $("#plan-orders-body"); ob.innerHTML = "";
  for (const o of (plan.orders || [])) {
    const tr = el("tr");
    tr.innerHTML = `<td><span class="badge ${o.side === "SELL" ? "sell" : "buy"}">${o.side}</span></td>
      <td><b>${esc(o.ticker)}</b>${o.is_new ? ' <span class="sig">new</span>' : ""}</td>
      <td>${pct(Math.abs(o.delta_pct), false)} of portfolio</td>
      <td>${o.amount_usd ? "$" + Number(o.amount_usd).toLocaleString() +
           (o.shares ? ` · ~${o.shares} sh` : "") : "—"}${o.cost_note ? ` <span class="sig">${esc(o.cost_note)}</span>` : ""}</td>`;
    ob.appendChild(tr);
  }
  $("#plan-orders-note").textContent =
    (plan.cash ? `Sells fund the buys; ${pct(plan.cash, false)} of the book stays in cash as dry powder for the watchlist. ` : `Cash-neutral: sells fund the buys. `) +
    `Based on ${plan.based_on}.` +
    ((plan.staged_trims || []).length ? ` ${plan.staged_trims.join(", ")} trimmed in stages — the book cannot absorb the full sale at sensible sizes; finish the cut as new ideas clear the bar.` : "") +
    ` Educational illustration, not advice.`;
  // triage
  const tw = $("#plan-triage-wrap");
  tw.hidden = !(plan.triage || []).length;
  const tg = $("#plan-triage"); tg.innerHTML = "";
  const dispIcon = { "average down": "🟢", "switch": "🔁", "exit to fund": "🔻", "hold and wait": "⏸", "hold — on watch": "👁" };
  for (const t of (plan.triage || [])) {
    tg.appendChild(el("div", "triage-row",
      `${dispIcon[t.disposition] || "•"} <b>${esc(t.ticker)}</b> — <i>${esc(t.disposition)}</i>: ${esc(t.note)}`));
  }
}

function sentDot(s) {
  const l = String(s || "").toLowerCase();
  if (l.includes("bull")) return '<span class="dot good"></span>';
  if (l.includes("bear")) return '<span class="dot bad"></span>';
  return '<span class="dot" style="background:var(--dim)"></span>';
}

function detailHTML(r) {
  const lensNames = { value: "🏛 Value", growth: "🚀 Growth", macro: "🌍 Macro", technical: "📈 Technical", risk: "🛡 Risk" };
  const convColor = r.conviction >= 63 ? "var(--good)" : r.conviction >= 45 ? "var(--accent)" : "var(--warn)";
  let left = `<div class="detail"><h4>Committee conviction</h4>
    <div class="conv-meter"><div class="conv-fill" style="width:${r.conviction}%;background:${convColor}"></div></div>
    <li style="list-style:none;margin-left:0">${r.conviction}/100 median across ${r.n_lenses} lenses` +
    (r.dispersion >= 30 ? ` — <span class="dn">wide disagreement (${r.dispersion} pts)</span>` : "") + `</li>`;
  const scores = r.lens_scores || {};
  for (const k of Object.keys(lensNames)) if (scores[k] !== undefined)
    left += `<li style="list-style:none;margin-left:0">${lensNames[k]}: ${Math.round(scores[k])}</li>`;
  left += ivBar(r) + `</div>`;
  const newsBlock = (r.news || []).length
    ? `<h4 style="margin-top:12px">News the committee weighed</h4>` +
      r.news.map(n => `<div class="news-row">${sentDot(n.sentiment)} ${esc(n.headline)}` +
        (n.sentiment ? ` <span class="news-sent">${esc(n.sentiment)}</span>` : "") + `</div>`).join("")
    : "";
  const valBits = [];
  if (r.intrinsic_low && r.intrinsic_high) valBits.push(`bear–bull range ${Number(r.intrinsic_low).toFixed(0)}–${Number(r.intrinsic_high).toFixed(0)}`);
  if (r.implied_growth !== null && r.implied_growth !== undefined)
    valBits.push(`today's price assumes <b>${r.implied_growth >= 0.6 ? "over 60" : (r.implied_growth * 100).toFixed(0)}%/yr</b> earnings growth`);
  if (r.valuation_model) valBits.push(`<span class="dim">${esc(r.valuation_model)}</span>`);
  const lotsHtml = (r.lots || []).length
    ? `<h4 style="margin-top:12px">Lots (average cost ${Number(r.entry).toFixed(2)})</h4><li>` +
      r.lots.map(l => `${l.side === "sell" ? "sold" : "bought"} ${l.qty ?? "?"} @ ${Number(l.entry).toFixed(2)}${l.bought ? " on " + esc(l.bought) : ""}`).join(" · ") +
      (r.lot_note ? ` <span class="dim">— ${esc(r.lot_note)}</span>` : "") + "</li>"
    : "";
  const peer = r.peer || {};
  const peerNames = { n_earnings_yield: "earnings yield", n_evebitda: "EV/EBITDA value", n_roe: "ROE", n_netmargin: "margin", n_mom_12_1: "momentum" };
  const peerBits = Object.entries(peer).map(([k, v]) => `${peerNames[k] || k} ${v}th`);
  const memo = r.memo || null;
  const memoHtml = memo ? `<h4 style="margin-top:12px">Investment memo</h4>` +
    (memo.thesis ? `<li><b>Thesis:</b> ${esc(memo.thesis)}</li>` : "") +
    (memo.debate ? `<li><b>The debate:</b> ${esc(memo.debate)}</li>` : "") +
    (memo.change_mind ? `<li><b>What would change our mind:</b> ${esc(memo.change_mind)}</li>` : "") : "";
  const a = r.asymmetry, sz = r.sizing;
  const asymHtml = a ? `<h4 style="margin-top:12px">Payoff asymmetry (DCF-implied, 12–18 months${a.gated === false ? " · growth-stage name: shown, not used to gate the action" : ""})</h4><li>` +
    `bear <span class="dn">${pct(a.bear)}</span> (${Math.round(a.p.bear * 100)}%) · base ${pct(a.base)} (${Math.round(a.p.base * 100)}%) · bull <span class="up">${pct(a.bull)}</span> (${Math.round(a.p.bull * 100)}%)` +
    ` → expected <b class="${a.expected_return >= 0 ? "up" : "dn"}">${pct(a.expected_return)}</b>, upside:downside <b>${a.up_down_ratio}:1</b> ` +
    `<span class="sig">${a.clears_hurdle ? "clears the 2:1 / +8% hurdle" : "below the 2:1 / +8% hurdle"}</span></li>` +
    (sz ? `<li class="dim">Sizing guardrail: ${sz.half_kelly > 0 ? `half-Kelly ${pct(sz.half_kelly, false)} of the book (cap ${pct(sz.cap, false)})` : "no addition justified at this payoff"} vs ${pct(sz.current, false)} held · volatility ${pct(sz.vol, false)}</li>` : "") : "";
  const us = r.us || null;
  let usHtml = "";
  if (us) {
    const e = us.econ || {}, pz = us.piotroski, vh = us.valuation_hist || {}, er = us.earnings || {}, ins = us.insiders || {}, inst = us.institutions || {};
    const f = (v, d = 1) => (v === null || v === undefined) ? null : Number(v).toFixed(d);
    const chips = [];
    if (pz) chips.push(`Piotroski <b>${pz.score}/9</b>`);
    if (us.altman_z !== null && us.altman_z !== undefined) chips.push(`Altman Z <b>${us.altman_z}</b> <span class="dim">(${us.altman_z > 2.99 ? "safe" : us.altman_z >= 1.8 ? "grey" : "distress"})</span>`);
    if (us.beneish_m !== null && us.beneish_m !== undefined) chips.push(`Beneish M <b>${us.beneish_m}</b> <span class="dim">(${us.beneish_m < -1.78 ? "clean" : "warning"})</span>`);
    if (e.accruals !== undefined && e.accruals !== null) chips.push(`accruals ${(e.accruals * 100).toFixed(1)}% of assets`);
    if (e.cash_conversion !== undefined && e.cash_conversion !== null) chips.push(`FCF/NI ${f(e.cash_conversion, 2)}`);
    const econ = [];
    if (e.roic !== null && e.roic !== undefined) econ.push(`ROIC ${pct(e.roic, false)}`);
    if (e.incremental_roic_5y !== undefined && e.incremental_roic_5y !== null) econ.push(`incremental ROIC (5y) ${pct(e.incremental_roic_5y, false)}`);
    else if (e.capital_light) econ.push(`capital-light growth`);
    if (e.owner_earnings_yield !== undefined && e.owner_earnings_yield !== null) econ.push(`owner-earnings yield ${pct(e.owner_earnings_yield, false)}`);
    if (e.fcf_yield !== undefined && e.fcf_yield !== null) econ.push(`FCF yield ${pct(e.fcf_yield, false)}`);
    if (e.greenblatt_ey !== undefined && e.greenblatt_ey !== null) econ.push(`EBIT/EV ${pct(e.greenblatt_ey, false)}`);
    if (e.peg_3y !== undefined && e.peg_3y !== null) econ.push(`PEG ${e.peg_3y}`);
    if (e.graham_number) econ.push(`Graham number ${e.graham_number}`);
    if (e.rule_of_40 !== undefined && e.rule_of_40 !== null) econ.push(`Rule of 40: ${e.rule_of_40}`);
    const cap = [];
    if (e.buyback_yield_1y !== undefined && e.buyback_yield_1y !== null) cap.push(`buyback yield ${pct(e.buyback_yield_1y)}`);
    if (e.share_change_5y !== undefined && e.share_change_5y !== null) cap.push(`shares 5y ${pct(e.share_change_5y)}`);
    if (e.sbc_pct_rev !== undefined && e.sbc_pct_rev !== null) cap.push(`SBC ${pct(e.sbc_pct_rev, false)} of revenue`);
    if (e.fcf_payout !== undefined && e.fcf_payout !== null) cap.push(`FCF payout ${pct(e.fcf_payout, false)}`);
    if (e.dividend_streak_years) cap.push(`dividend raised ${e.dividend_streak_years} years running`);
    const val = Object.entries(vh).filter(([k, v]) => v && typeof v === "object").map(([k, v]) => `${k.toUpperCase()} ${v.now} <span class="dim">(${v.pctile}th pctile of own ${vh.years}y, median ${v.median})</span>`);
    const mom = [];
    if (er.n_q) mom.push(`beat ${er.beats_8q}/${er.n_q} quarters (avg surprise ${er.avg_surprise_pct > 0 ? "+" : ""}${er.avg_surprise_pct}%)`);
    if (er.eps_revision_90d_pct !== undefined && er.eps_revision_90d_pct !== null) mom.push(`FY EPS estimate ${er.eps_revision_90d_pct > 0 ? "+" : ""}${er.eps_revision_90d_pct}% in 90d`);
    if (er.revisions_30d) mom.push(`revisions 30d ${er.revisions_30d.up} up / ${er.revisions_30d.down} down`);
    const own = [];
    if (ins.months) own.push(`insiders 6m: bought $${(ins.buy_usd / 1e6).toFixed(1)}M (${ins.n_buyers}) / sold $${(ins.sell_usd / 1e6).toFixed(1)}M (${ins.n_sellers})`);
    if (inst.holders_change !== undefined && inst.holders_change !== null) own.push(`13F holders ${inst.holders_change > 0 ? "+" : ""}${inst.holders_change} q/q${inst.shares_change_pct !== undefined && inst.shares_change_pct !== null ? `, shares held ${inst.shares_change_pct > 0 ? "+" : ""}${inst.shares_change_pct}%` : ""}`);
    usHtml = `<h4 style="margin-top:12px">Deep fundamentals <span class="dim">(FY ${esc(us.fiscal_year || "")}${e.lynch_category ? " · Lynch: " + esc(e.lynch_category) : ""})</span></h4>` +
      (chips.length ? `<li><b>Forensics:</b> ${chips.join(" · ")}</li>` : "") +
      (econ.length ? `<li><b>Economics:</b> ${econ.join(" · ")}</li>` : "") +
      (cap.length ? `<li><b>Capital allocation:</b> ${cap.join(" · ")}</li>` : "") +
      (val.length ? `<li><b>Vs own history:</b> ${val.join(" · ")}</li>` : "") +
      (mom.length ? `<li><b>Earnings momentum:</b> ${mom.join(" · ")}</li>` : "") +
      (own.length ? `<li><b>Ownership:</b> ${own.join(" · ")}</li>` : "") +
      ((us.flags || []).length ? `<li class="dn"><b>Forensic flags:</b> ${esc(us.flags.join("; "))}</li>` : "") +
      ((us.sell_triggers || []).length ? `<h4 style="margin-top:12px">Sell triggers — what would break the thesis</h4>` + us.sell_triggers.map(t => `<li>${esc(t)}</li>`).join("") : "");
  }
  let right = `<div class="detail"><h4>Strengths</h4>` +
    (r.strengths.length ? r.strengths.map(s => `<li>${esc(s)}</li>`).join("") : "<li>—</li>") +
    `<h4 style="margin-top:12px">Risks</h4>` +
    (r.risks.length ? r.risks.map(s => `<li>${esc(s)}</li>`).join("") : "<li>—</li>") +
    (valBits.length ? `<h4 style="margin-top:12px">Valuation</h4><li>${valBits.join(" · ")}</li>` : "") +
    memoHtml + asymHtml + usHtml +
    lotsHtml +
    (peerBits.length ? `<h4 style="margin-top:12px">Peer percentiles (${esc(r.peer_universe || "US")} universe)</h4><li>${peerBits.join(" · ")}</li>` : "") +
    (r.tax_note ? `<h4 style="margin-top:12px">Tax</h4><li>${esc(r.tax_note)}</li>` : "") +
    (r.flags.length ? `<h4 style="margin-top:12px">Guards</h4><li>${esc(r.flags.join(", "))}</li>` : "") +
    (r.data_flags && r.data_flags.length ? `<h4 style="margin-top:12px">Data quality</h4><li class="dim">${esc(r.data_flags.join("; "))}</li>` : "") +
    (r.data_source ? `<li class="dim" style="margin-top:8px">Fundamentals: ${esc(r.data_source)}${r.fund_asof ? " as of " + esc(r.fund_asof) : ""}${r.price_asof ? " · price " + esc(r.price_asof) : ""}</li>` : "") +
    newsBlock +
    `</div>`;
  return `<div class="detail-grid">${left}${right}</div>`;
}

function renderConstruction(plan) {
  const box = $("#plan-construction"); if (!box) return; box.innerHTML = "";
  const rc = plan.risk_contributions || {};
  let html = "";
  if ((rc.top || []).length) {
    html += `<h4>Where your risk actually sits</h4><div class="risk-line">` +
      rc.top.map(([t, p]) => `<b>${esc(t)}</b> ${pct(p, false)} of risk` +
        (rc.contrib && rc.contrib[t] ? ` <span class="dim">(${pct(rc.contrib[t].weight, false)} of money)</span>` : "")).join(" · ") +
      (rc.portfolio_vol ? ` <span class="dim">· portfolio volatility ${pct(rc.portfolio_vol, false)}</span>` : "") + `</div>`;
  }
  const picks = plan.picks || [];
  if (picks.length) {
    html += `<h4>Adds chosen jointly (each given the one before it)</h4><div class="risk-line">` +
      picks.map(p => p.variance_reduction === null || p.variance_reduction === undefined
        ? `<b>${p.order}. ${esc(p.ticker)}</b> ${esc(p.reason || "")}`
        : `<b>${p.order}. ${esc(p.ticker)}</b> cuts portfolio variance ${pct(p.variance_reduction, false)} (vol ${pct(p.vol_before, false)} → ${pct(p.vol_after, false)})`).join(" · ") + `</div>`;
  }
  const ra = plan.risk_contributions_after || {};
  if ((ra.top || []).length) {
    html += `<h4>Where the risk sits after the trade</h4><div class="risk-line">` +
      ra.top.map(([t, p]) => `<b>${esc(t)}</b> ${pct(p, false)} of risk`).join(" · ") +
      (ra.portfolio_vol ? ` <span class="dim">· portfolio volatility ${pct(ra.portfolio_vol, false)}</span>` : "") + `</div>`;
  }
  const s12 = plan.scenarios_12m || {}; const c12 = s12.current || {}; const p12 = s12.planned || {};
  if (c12.base !== undefined && c12.base !== null) {
    html += `<h4>Valuation risk/reward — where the book would trade if every holding converged to its bear / base / bull value</h4><table class="mini-table"><thead><tr><th></th><th>Bear</th><th>Base</th><th>Bull</th><th>Covered</th></tr></thead><tbody>` +
      `<tr><td><b>Today</b></td><td>${pct(c12.bear)}</td><td>${pct(c12.base)}</td><td>${pct(c12.bull)}</td><td>${pct(c12.coverage, false)}</td></tr>` +
      (p12.base !== undefined && p12.base !== null ? `<tr><td><b>After the plan</b></td><td>${pct(p12.bear)}</td><td>${pct(p12.base)}</td><td>${pct(p12.bull)}</td><td>${pct(p12.coverage, false)}</td></tr>` : "") +
      `</tbody></table>`;
  }
  if ((plan.tax_plan || []).length) {
    html += `<h4>Tax plan — how the sells are executed</h4><ul class="plain">` +
      plan.tax_plan.map(x => `<li><b>${esc(x.ticker)}</b> ${esc(x.note)}</li>`).join("") + `</ul>`;
  }
  if ((plan.watchlist || []).length) {
    html += `<h4>Watchlist — the bench, with entry conditions</h4><ul class="plain">` +
      plan.watchlist.map(x => `<li><b>${esc(x.ticker)}</b> ${esc(x.name || "")} — <i>${esc(x.condition)}</i> <span class="dim">(${esc(x.why || "")})</span></li>`).join("") + `</ul>`;
  }
  if ((plan.catalysts || []).length) {
    html += `<h4>Catalysts — next 90 days</h4><div class="risk-line">` +
      plan.catalysts.map(x => `<b>${esc(x.ticker)}</b> ${esc(x.event)} ${esc(x.date)}`).join(" · ") + `</div>`;
  }
  const sc = plan.scenarios || {}; const pt = plan.post_trade || {}; const cur = pt.current || {};
  const names = Object.keys(sc);
  if (names.length) {
    html += `<h4>Three ways to get there</h4><table class="mini-table"><thead><tr><th>Scenario</th><th>Turnover</th><th>Est. tax</th><th>Volatility</th><th>Worst stress replay</th><th>Effective positions</th><th>Est. health</th></tr></thead><tbody>` +
      `<tr><td><b>Today</b></td><td>–</td><td>–</td><td>${cur.vol ? pct(cur.vol, false) : "–"}</td><td>${worstStress(cur)}</td><td>${cur.effective_n ?? "–"}</td><td>${cur.est_health ?? "–"}</td></tr>` +
      names.map(n => { const s = sc[n], p = pt[n] || {}; return `<tr><td>${esc(s.label)}${s.tolerance_met === true ? ' <span class="sig">within your loss tolerance</span>' : s.tolerance_met === false ? ' <span class="sig dn">loss tolerance not reachable within these bounds</span>' : ""}</td><td>${pct(s.turnover, false)}</td><td>${s.est_tax_usd !== null && s.est_tax_usd !== undefined ? "$" + Number(s.est_tax_usd).toLocaleString() : pct(s.est_tax_pct_of_book, false) + " of book"}</td><td>${p.vol ? pct(p.vol, false) : "–"}</td><td>${worstStress(p)}</td><td>${p.effective_n ?? "–"}</td><td><b>${p.est_health ?? "–"}</b></td></tr>`; }).join("") +
      `</tbody></table><div class="hint">The order plan below follows the full rebalance; the tax-light and new-cash routes show what you give up by trading less.</div>`;
  }
  box.innerHTML = html;
}
function worstStress(blk) {
  const st = (blk && blk.stress) || []; if (!st.length) return "–";
  const w = st.reduce((a, b) => (b.portfolio < a.portfolio ? b : a));
  return `<span class="dn">${pct(w.portfolio)}</span> <span class="dim">(${esc(w.name)})</span>`;
}

function renderRejected(R) {
  const rej = R.rejected_candidates || []; const sec = $("#rejected-section"); if (!sec) return;
  sec.hidden = !rej.length; const body = $("#rejected-body"); body.innerHTML = "";
  for (const r of rej) body.appendChild(el("div", "rej", `<b>${esc(r.ticker)}</b> <span class="dim">${esc(r.stage)}</span> — ${esc(r.reason)}`));
}

function renderRisk(R) {
  const rk = R.risk || {}; const sec = $("#risk-section"); const body = $("#risk-body");
  const bn = (R.benchmark && R.benchmark.name) || "benchmark";
  let html = "";
  if ((rk.stress || []).length) {
    html += `<h4>Stress replay — what today's weights would have done</h4><table class="mini-table"><thead><tr><th>Shock</th><th>Window</th><th>Portfolio</th><th>${esc(bn)}</th></tr></thead><tbody>` +
      rk.stress.map(s => `<tr><td>${esc(s.name)}</td><td class="dim">${esc(s.window)}</td><td class="${s.portfolio >= 0 ? "up" : "dn"}">${pct(s.portfolio)}</td><td>${s.benchmark === null || s.benchmark === undefined ? "–" : pct(s.benchmark)}</td></tr>`).join("") +
      `</tbody></table>`;
  }
  const chips = [];
  if (rk.var_1d_95 !== undefined && rk.var_1d_95 !== null)
    chips.push(`<b>Value at risk (95%)</b> 1-day ${pct(rk.var_1d_95, false)} · 1-month ${pct(rk.var_1m_95, false)}` +
      (rk.cvar_1d_95 ? ` · tail loss ${pct(rk.cvar_1d_95, false)}` : ""));
  if (rk.tracking_error) chips.push(`<b>Tracking error vs ${esc(bn)}</b> ${pct(rk.tracking_error, false)}`);
  if (rk.currency_exposure && Object.keys(rk.currency_exposure).length > 1)
    chips.push(`<b>Currency</b> ` + Object.entries(rk.currency_exposure).map(([c, w]) => `${esc(c)} ${pct(w, false)}`).join(" · "));
  const lt = rk.lookthrough || {};
  if ((lt.top_names || []).length)
    chips.push(`<b>Fund look-through</b> (constituents disclosed for ${pct(lt.coverage, false)} of fund weight): ` +
      lt.top_names.slice(0, 6).map(([n, d, v, t]) => `${esc(n)} ${pct(t, false)}` + (d && v ? ` <span class="dim">(direct ${pct(d, false)} + via funds ${pct(v, false)})</span>` : "")).join(" · "));
  const ft = rk.factor_tilts || {};
  if (ft.tilts) chips.push(`<b>Style tilts</b> (${pct(ft.coverage, false)} of book): ` +
    Object.entries(ft.tilts).map(([k, v]) => `${esc(k)} <span class="${v >= 0 ? "up" : "dn"}">${v >= 0 ? "+" : ""}${Number(v).toFixed(2)}</span>`).join(" · "));
  const fb = rk.factor_betas || {};
  if (fb.betas) chips.push(`<b>Measured factor betas</b> (vs ${esc(bn)}, ${fb.n_days} days; market beta ${fb.market_beta}): ` +
    Object.entries(fb.betas).map(([k, v]) => `${esc(k.replace("_", " "))} <span class="${v >= 0 ? "up" : "dn"}">${v >= 0 ? "+" : ""}${Number(v).toFixed(2)}</span>`).join(" · "));
  const br = rk.benchmark_relative || {};
  if (br.upside_capture !== undefined || br.sortino !== undefined) {
    const bits = [];
    if (br.upside_capture !== undefined) bits.push(`upside capture ${Number(br.upside_capture).toFixed(2)}`);
    if (br.downside_capture !== undefined) bits.push(`downside capture ${Number(br.downside_capture).toFixed(2)}`);
    if (br.sortino !== undefined) bits.push(`Sortino ${br.sortino}`);
    if (br.calmar !== undefined) bits.push(`Calmar ${br.calmar}`);
    if (br.active_return_ann !== undefined) bits.push(`active return ${pct(br.active_return_ann)}/yr`);
    chips.push(`<b>Vs ${esc(bn)}</b> ` + bits.join(" · "));
  }
  if ((rk.active_sectors || []).length)
    chips.push(`<b>Active sector bets vs ${esc(bn)}</b> ` + rk.active_sectors.slice(0, 6).map(x => `${esc(x.sector)} <span class="${x.active >= 0 ? "up" : "dn"}">${x.active >= 0 ? "+" : ""}${(x.active * 100).toFixed(0)}pp</span> <span class="dim">(${pct(x.book, false)} vs ${pct(x.benchmark, false)})</span>`).join(" · "));
  if ((rk.liquidity || []).length)
    chips.push(`<b>Liquidity</b> ` + rk.liquidity.slice(0, 4).map(x => `${esc(x.ticker)} ≈ ${x.days_to_liquidate} days to exit`).join(" · "));
  html += chips.map(c => `<div class="risk-line">${c}</div>`).join("");
  body.innerHTML = html;
  sec.hidden = !html;
}

function ivBar(r) {
  if (!r.intrinsic) return "";
  const pts = [["Entry", r.entry, "var(--dim)"], ["Now", r.price, "var(--accent)"],
               ["Intrinsic", r.intrinsic, "var(--good)"]];
  const vals = pts.map(p => p[1]);
  const lo = Math.min(...vals) * 0.92, hi = Math.max(...vals) * 1.08;
  const x = v => ((v - lo) / (hi - lo) * 100).toFixed(1);
  return `<h4 style="margin-top:14px">Price vs value${r.lifecycle ? ` · ${esc(r.lifecycle)}` : ""}</h4>
    <div class="iv-bar"><div class="iv-track"></div>` +
    pts.map(([lab, v, c]) =>
      `<div class="iv-mark" style="left:${x(v)}%;background:${c}"><span>${lab} ${Number(v).toFixed(0)}</span></div>`).join("") +
    `</div>`;
}

function renderDonut(R) {
  const svg = $("#donut"); svg.innerHTML = "";
  const entries = Object.entries(R.sector_allocation);
  let a0 = -Math.PI / 2;
  const cx = 100, cy = 100, r1 = 58, r2 = 88;
  entries.forEach(([s, w], i) => {
    const a1 = a0 + w * 2 * Math.PI;
    const large = (a1 - a0) > Math.PI ? 1 : 0;
    const p = (a, r) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
    const d = `M ${p(a0, r2)} A ${r2} ${r2} 0 ${large} 1 ${p(a1, r2)} L ${p(a1, r1)} A ${r1} ${r1} 0 ${large} 0 ${p(a0, r1)} Z`;
    const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
    path.setAttribute("d", d);
    path.setAttribute("fill", SECTOR_COLORS[i % SECTOR_COLORS.length]);
    path.setAttribute("opacity", "0.9");
    svg.appendChild(path);
    a0 = a1;
  });
  const legend = $("#donut-legend"); legend.innerHTML = "";
  const [lo, hi] = R.sector_band;
  entries.forEach(([s, w], i) => {
    const over = w > hi ? ` <span class="over">above ${Math.round(hi * 100)}% band</span>` : "";
    legend.appendChild(el("div", null,
      `<span class="sw" style="background:${SECTOR_COLORS[i % SECTOR_COLORS.length]}"></span>${esc(s)}<span class="pct">${pct(w, false)}</span>${over}`));
  });
}

function renderWeights(R) {
  const wrap = $("#weight-bars"); wrap.innerHTML = "";
  const cap = R.name_cap;
  const maxW = Math.max(...R.holdings.map(h => h.weight), cap) * 1.15;
  for (const h of R.holdings) {
    const wb = el("div", "wb");
    const over = h.weight > cap;
    wb.innerHTML = `<span>${esc(h.ticker)}</span>
      <div class="track"><div class="fill ${over ? "over" : ""}" style="width:${(h.weight / maxW * 100).toFixed(1)}%"></div>
        <div class="cap" style="left:${(cap / maxW * 100).toFixed(1)}%"></div></div>
      <span class="w">${pct(h.weight, false)}</span>`;
    wrap.appendChild(wb);
  }
  $("#cap-hint").textContent = `Red line = ${Math.round(cap * 100)}% healthy single-position cap.`;
}

$("#rerun").onclick = () => show("input");