From aa38208fff6849de85a80b8cda6dfdc022028a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 4 Jun 2026 00:42:07 +0800 Subject: [PATCH] update dashboard --- app.css | 423 +++++++++++++++- app.js | 1065 +++++++++++++++++++++++++++++++++++++--- index.html | 238 ++++++--- lib/calendar-cache.js | 144 ++++++ lib/calendar-fred.js | 69 +++ lib/calendar-i18n.js | 180 +++++++ lib/calendar-market.js | 196 ++++++++ lib/calendar.js | 359 ++++++++++++++ lib/companyintel.js | 204 ++++++++ lib/context.js | 213 ++++++++ lib/fred.js | 9 +- lib/fundamentals.js | 161 +++++- lib/glossary.js | 476 ++++++++++++++++++ lib/learn-html.js | 359 ++++++++++++++ lib/marketdata.js | 98 ++-- server.js | 149 +++++- 16 files changed, 4171 insertions(+), 172 deletions(-) create mode 100644 lib/calendar-cache.js create mode 100644 lib/calendar-fred.js create mode 100644 lib/calendar-i18n.js create mode 100644 lib/calendar-market.js create mode 100644 lib/calendar.js create mode 100644 lib/companyintel.js create mode 100644 lib/context.js create mode 100644 lib/glossary.js create mode 100644 lib/learn-html.js diff --git a/app.css b/app.css index 3021eca..745012e 100644 --- a/app.css +++ b/app.css @@ -24,8 +24,9 @@ body:not([data-view="macro"]) #navLinks{display:none} /* ── 頁面 ── */ .page{margin:28px 32px 0;animation:fadeInUp .35s ease both} .page-head{margin-bottom:22px} -.page-title{font-size:1.5rem;font-weight:700;letter-spacing:-.02em} +.page-title{font-size:1.5rem;font-weight:800;letter-spacing:0} .page-sub{font-size:.9rem;color:var(--text2);margin-top:8px;line-height:1.65;max-width:720px} +.eyebrow{font-size:.72rem;color:var(--text2);font-weight:800;letter-spacing:.09em;text-transform:uppercase;margin-bottom:8px} .disclaimer{ font-size:.78rem;color:var(--text2); background:rgba(255,149,0,.08);border:1px solid rgba(255,149,0,.18); @@ -78,9 +79,23 @@ body:not([data-view="macro"]) #navLinks{display:none} .tile.on.tint-red{border-color:var(--red);background:rgba(255,59,48,.06)} /* ═══════════ 學習教材 ═══════════ */ +.learn-hero{ + display:grid;grid-template-columns:minmax(0,1fr) auto;gap:24px;align-items:end; + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:24px; + box-shadow:var(--shadow); +} +.learn-stats{display:flex;gap:12px;flex-wrap:wrap;justify-content:flex-end} +.learn-stats div{ + min-width:96px;background:#f9faf7;border:1px solid var(--border);border-radius:12px; + padding:12px 14px;text-align:left; +} +.learn-stats b{display:block;font-size:1.35rem;line-height:1;color:var(--text)} +.learn-stats span{display:block;font-size:.72rem;color:var(--text2);margin-top:6px} .learn-layout{display:grid;grid-template-columns:240px 1fr;gap:24px;align-items:start} .learn-side{ position:sticky;top:88px;display:flex;flex-direction:column;gap:6px; + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px; + box-shadow:0 1px 2px rgba(32,40,33,.04); } .learn-side .side-group{ font-size:.68rem;color:var(--text2);letter-spacing:.08em;margin:14px 4px 6px; @@ -91,21 +106,114 @@ body:not([data-view="macro"]) #navLinks{display:none} cursor:pointer;transition:.15s;display:flex;justify-content:space-between;align-items:center;gap:8px; border:1px solid transparent;background:transparent;text-decoration:none; } -.learn-side a:hover,.side-tile:hover{background:rgba(0,0,0,.04)} +.learn-side a:hover,.side-tile:hover{background:#f9faf7} .learn-side a.active,.side-tile.active{ - background:var(--surface);border-color:var(--border); - box-shadow:var(--shadow);font-weight:600; + background:#202421;color:#fff;border-color:#202421; + box-shadow:0 8px 18px rgba(32,40,33,.14);font-weight:700; } +.learn-side a.active .count{color:#202421;background:#fff} .learn-side a .count{font-size:.68rem;color:var(--text2);background:rgba(0,0,0,.05); padding:2px 8px;border-radius:20px} .learn-content{min-width:0} @media(max-width:780px){ + .learn-hero{grid-template-columns:1fr} + .learn-stats{justify-content:flex-start} .learn-layout{grid-template-columns:1fr} .learn-side{position:static;flex-direction:row;flex-wrap:wrap} .learn-side .side-group{width:100%} } +.learning-board{ + display:grid;grid-template-columns:minmax(220px,.82fr) minmax(0,1.4fr);gap:18px; + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px; + box-shadow:var(--shadow);margin-bottom:16px; +} +.board-copy h2{font-size:1.35rem;line-height:1.25;margin-bottom:10px} +.board-copy p{font-size:.88rem;color:var(--text2);line-height:1.75} +.learning-cards{display:grid;gap:10px} +.learning-card{ + display:grid;grid-template-columns:auto 1fr;gap:5px 14px;text-align:left; + border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:15px 16px; + color:var(--text);font-family:inherit;cursor:pointer;transition:.18s; +} +.learning-card:hover{background:#fff;border-color:rgba(35,103,199,.28);box-shadow:var(--soft-shadow);transform:translateY(-1px)} +.learning-card .lc-step{ + grid-row:1/4;width:34px;height:34px;border-radius:10px;background:#202421;color:#fff; + display:flex;align-items:center;justify-content:center;font-size:.74rem;font-weight:800; +} +.learning-card strong{font-size:.96rem;line-height:1.35} +.learning-card span:not(.lc-step){font-size:.8rem;color:var(--text2);line-height:1.55} +.learning-card em{font-style:normal;color:var(--blue);font-size:.78rem;font-weight:760} +.practice-strip{ + display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px; +} +.practice-strip div{ + background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px; +} +.practice-strip b{display:block;font-size:.86rem;margin-bottom:5px} +.practice-strip span{font-size:.76rem;color:var(--text2);line-height:1.5} +.learn-shortcuts .module-card{min-height:110px} + +/* ── 學習 HTML 文章 ── */ +.learn-path-host{margin-bottom:18px} +.learn-path-head{margin-bottom:12px} +.learn-path-steps{display:grid;gap:8px} +.path-step{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 14px} +.path-step summary{cursor:pointer;font-size:.88rem;list-style:none;display:flex;align-items:center;gap:10px} +.path-step summary::-webkit-details-marker{display:none} +.path-n{width:22px;height:22px;border-radius:50%;background:rgba(35,103,199,.12);color:var(--blue);display:inline-flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:700;flex-shrink:0} +.path-step p{font-size:.82rem;color:var(--text2);margin:10px 0 8px;line-height:1.55} +.path-actions{display:flex;flex-wrap:wrap;gap:8px} +.la-link,.la-practice{ + border:1px solid var(--border);background:#f9faf7;border-radius:999px;padding:6px 12px; + font-size:.76rem;cursor:pointer;color:var(--text); +} +.la-link:hover{border-color:rgba(175,82,222,.35);color:var(--purple)} +.la-practice{background:rgba(35,103,199,.08);border-color:rgba(35,103,199,.2);color:var(--blue);font-weight:650} +.learn-article .la-head{margin-bottom:16px} +.learn-article .la-kind{font-size:.72rem;color:var(--blue);font-weight:700;margin-bottom:6px} +.learn-article .la-title{font-size:1.45rem;line-height:1.25;margin:0 0 8px} +.learn-article .la-lead{font-size:.92rem;color:var(--text2);line-height:1.6;margin:0} +.la-principles{background:#f7f9f5;border:1px solid var(--border);border-radius:14px;padding:14px 16px;margin:16px 0} +.la-principles-head h2{font-size:.95rem;margin:0 0 4px} +.la-principles-head p{font-size:.78rem;color:var(--text2);margin:0 0 10px;line-height:1.5} +.la-principle-chips,.learn-article .principle-chips{display:flex;flex-wrap:wrap;gap:8px} +.principle-chip,.learn-article .principle-chip{ + border:1px solid rgba(175,82,222,.25);background:rgba(175,82,222,.08);color:#5b3f99; + border-radius:999px;padding:6px 12px;font-size:.76rem;cursor:pointer; +} +.principle-chip:hover{background:rgba(175,82,222,.14)} +.la-toc{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 14px;margin:0 0 16px} +.la-toc-title{font-size:.72rem;color:var(--text2);margin-bottom:8px;font-weight:700} +.la-toc-links{display:flex;flex-wrap:wrap;gap:6px 10px} +.la-toc-links a{font-size:.78rem;color:var(--blue);text-decoration:none} +.la-toc-links a.lv3{padding-left:8px;color:var(--text2)} +.learn-body{margin-top:4px} +.la-footer{margin-top:22px;padding-top:16px;border-top:1px solid var(--border)} +.la-footer-title{font-size:.82rem;font-weight:700;margin-bottom:10px} +.la-tool-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px} +.la-tool-card{ + text-align:left;border:1px solid var(--border);background:var(--surface);border-radius:12px;padding:12px 14px;cursor:pointer; +} +.la-tool-card strong{display:block;font-size:.86rem;margin-bottom:4px} +.la-tool-card span{font-size:.74rem;color:var(--text2)} +.la-tool-card:hover{border-color:rgba(35,103,199,.28);box-shadow:var(--soft-shadow)} +.case-badge{font-size:.62rem;color:var(--purple);background:rgba(175,82,222,.1);padding:2px 7px;border-radius:999px;margin-left:6px;vertical-align:middle} +.principle-groups{display:grid;gap:10px} +.principle-group{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 12px} +.principle-group summary{cursor:pointer;font-weight:700;font-size:.88rem} +.pg-count{font-size:.68rem;color:var(--text2);font-weight:500;margin-left:6px} +.pg-grid{margin-top:10px} +.pg-card .mod-name{font-size:.82rem;line-height:1.35} +@media(max-width:760px){ + .la-tool-grid{grid-template-columns:1fr} +} +@media(max-width:860px){ + .learning-board{grid-template-columns:1fr} + .practice-strip{grid-template-columns:1fr} +} + .stage{margin-bottom:28px} .stage-title{font-size:1.1rem;font-weight:700;margin-bottom:6px} .stage-badge{font-size:.66rem;font-weight:700;padding:3px 10px;border-radius:20px} @@ -116,7 +224,7 @@ body:not([data-view="macro"]) #navLinks{display:none} padding:18px 20px;cursor:pointer;transition:transform .15s,box-shadow .2s; display:flex;flex-direction:column;gap:8px;box-shadow:var(--shadow); } -.module-card:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(0,0,0,.1)} +.module-card:hover{transform:translateY(-2px);box-shadow:var(--soft-shadow)} .module-card .mod-name{font-size:.98rem;font-weight:700} .module-card .mod-meta{font-size:.76rem;color:var(--text2);line-height:1.55} .module-card .mod-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px} @@ -238,6 +346,311 @@ body:not([data-view="macro"]) #navLinks{display:none} .sub-tabs a.active{background:var(--surface);color:var(--blue);box-shadow:0 1px 4px rgba(0,0,0,.08)} .stk-pane[hidden]{display:none} +.metric-head{ + display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap; + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px; + box-shadow:var(--shadow);margin-bottom:12px; +} +.metric-head b{display:block;font-size:1rem} +.metric-head span{display:block;font-size:.78rem;color:var(--text2);margin-top:4px} +.metric-source-note{ + font-size:.78rem;line-height:1.65;color:var(--text2);background:#f9faf7; + border:1px solid var(--border);border-radius:12px;padding:12px 14px;margin-bottom:16px; +} +.metric-board{display:grid;gap:18px} +.decision-panel{ + border:1px solid var(--border);border-radius:16px;padding:20px 22px;background:var(--surface); + box-shadow:var(--shadow);border-left:6px solid var(--text2); +} +.decision-panel.good{border-left-color:var(--green)} +.decision-panel.warn{border-left-color:var(--orange)} +.decision-panel.bad{border-left-color:var(--red)} +.decision-kicker{font-size:.7rem;color:var(--text2);font-weight:800;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px} +.decision-title{font-size:1.55rem;font-weight:850;line-height:1.2} +.decision-panel.good .decision-title{color:var(--green)} +.decision-panel.warn .decision-title{color:var(--orange)} +.decision-panel.bad .decision-title{color:var(--red)} +.decision-score{font-size:.82rem;color:var(--text2);margin-top:6px} +.decision-cols{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:16px} +.decision-cols div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px 14px} +.decision-cols b{display:block;font-size:.78rem;margin-bottom:8px} +.decision-cols p{font-size:.76rem;color:var(--text2);line-height:1.55;margin:6px 0} +.metric-section{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px; + box-shadow:var(--shadow); +} +.metric-section-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap} +.metric-section-head h3{font-size:.98rem;line-height:1.2} +.metric-section-head span{font-size:.74rem;color:var(--text2)} +.metric-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px} +.metric-card{ + min-height:106px;border:1px solid var(--border);border-radius:12px;padding:12px; + background:#f9faf7;display:flex;flex-direction:column;gap:7px; +} +.metric-card.good{border-color:rgba(31,157,102,.22);background:rgba(31,157,102,.06)} +.metric-card.warn{border-color:rgba(200,138,29,.26);background:rgba(200,138,29,.07)} +.metric-card.bad{border-color:rgba(216,79,69,.24);background:rgba(216,79,69,.06)} +.metric-card.missing{border-style:dashed;opacity:.78} +.info-btn{ + width:18px;height:18px;border-radius:50%;border:1px solid var(--border);background:var(--surface); + color:var(--text2);font-size:.7rem;line-height:1;cursor:help;flex-shrink:0;padding:0; + display:inline-flex;align-items:center;justify-content:center;transition:.15s;font-weight:700; + vertical-align:middle;margin-left:3px; +} +.info-btn:hover,.info-btn:focus{border-color:var(--blue);color:var(--blue);outline:none} +.metric-name{ + font-size:.72rem;color:var(--text2);line-height:1.35; + display:flex;align-items:flex-start;justify-content:space-between;gap:4px; +} +.metric-name .info-btn{margin-top:0;flex-shrink:0} +.metric-section-head h3{display:inline-flex;align-items:center;gap:4px;flex-wrap:wrap} +.page-sub .info-btn,.event-title .info-btn{margin-left:2px;vertical-align:middle} +.metric-value{font-size:1.12rem;font-weight:800;line-height:1.2;color:var(--text);word-break:break-word} +.metric-card.good .metric-value{color:var(--green)} +.metric-card.warn .metric-value{color:var(--orange)} +.metric-card.bad .metric-value{color:var(--red)} +.metric-card.missing .metric-value{font-size:.92rem;color:var(--text2)} +.metric-note{font-size:.7rem;color:var(--text2);line-height:1.45;margin-top:auto} +.strategy-panel{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px; + box-shadow:var(--shadow); +} +.strategy-table{display:grid;gap:8px;margin-bottom:14px} +.strategy-row{ + display:grid;grid-template-columns:minmax(180px,1fr) 120px 100px;gap:12px;align-items:center; + background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px 12px; + font-size:.78rem; +} +.strategy-row b{display:block;font-size:.82rem} +.strategy-row span{display:block;color:var(--text2);font-size:.7rem;margin-top:3px} +.formula-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px} +.formula-card{border:1px solid var(--border);border-radius:12px;background:#fbfcfa;padding:12px} +.formula-title{font-size:.78rem;font-weight:800;margin-bottom:8px;display:flex;align-items:center;gap:4px;flex-wrap:wrap} +.formula-card pre{ + background:#202421;color:#e9efe7;border-radius:10px;padding:10px;overflow:auto; + font-size:.76rem;line-height:1.45;margin:0 0 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; +} +.formula-note{font-size:.72rem;color:var(--text2);line-height:1.45} +.calendar-page .page-sub{max-width:880px} +.calendar-hero{display:flex;justify-content:space-between;gap:18px;align-items:flex-start} +.calendar-watch-panel{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:14px 16px; + margin-bottom:16px;box-shadow:var(--soft-shadow);position:relative;z-index:2; +} +.cal-intro{ + background:#f7f9f5;border:1px solid var(--border);border-radius:14px;padding:14px 16px; + margin-bottom:16px;font-size:.78rem;line-height:1.55;color:var(--text2); +} +.cal-intro b{display:block;color:var(--text);font-size:.84rem;margin-bottom:6px} +.cal-intro p{margin:0 0 8px} +.cal-intro ul{margin:0;padding-left:18px} +.cal-intro li{margin:4px 0} +.cal-intro .info-btn.demo{ + display:inline-flex;width:18px;height:18px;font-size:.62rem;vertical-align:middle; + pointer-events:none;margin:0 2px; +} +.cal-more-inline{font-size:.7rem;color:var(--text2);background:rgba(102,112,100,.12);padding:1px 6px;border-radius:999px} +.calendar-watch-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px} +.calendar-watch-head b{display:block;font-size:.88rem} +.calendar-watch-head span{display:block;font-size:.72rem;color:var(--text2);margin-top:3px} +.calendar-watch-add{display:flex;gap:8px;align-items:center;flex:1;justify-content:flex-end;min-width:240px} +.calendar-watch-add input{ + flex:1;min-width:0;max-width:260px;border:1px solid var(--border);border-radius:9px;padding:8px 11px; + background:#f9faf7;color:var(--text);font-size:.86rem; +} +.calendar-watch-add button{ + border:0;background:var(--blue);color:#fff;border-radius:9px;padding:8px 14px;font-weight:800;cursor:pointer;white-space:nowrap; +} +.watch-chip-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-height:36px} +.watch-empty{font-size:.76rem;color:var(--text2);line-height:1.5} +.watch-chip{ + display:inline-flex;align-items:center;gap:4px;padding:4px 6px 4px 10px;border-radius:999px; + background:rgba(35,103,199,.08);border:1px solid rgba(35,103,199,.22);font-size:.78rem;font-weight:800;color:var(--blue); +} +.watch-chip-label{line-height:1.2} +.watch-chip-x{ + width:22px;height:22px;border:0;border-radius:50%;background:var(--surface);color:var(--text2); + cursor:pointer;font-size:1rem;line-height:1;padding:0;flex-shrink:0; +} +.watch-chip-x:hover{background:var(--red);color:#fff} +.calendar-msg{font-size:.74rem;margin-top:8px;padding:8px 10px;border-radius:8px;line-height:1.45} +.calendar-msg.good{background:rgba(31,157,102,.08);color:#1a6b45;border:1px solid rgba(31,157,102,.18)} +.calendar-msg.warn{background:rgba(200,138,29,.08);color:#8a5a12;border:1px solid rgba(200,138,29,.18)} +.calendar-msg.bad{background:rgba(216,79,69,.08);color:#9a3a32;border:1px solid rgba(216,79,69,.16)} +.calendar-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:12px} +.calendar-stat{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--soft-shadow);min-width:0; +} +.calendar-stat b{display:block;font-size:1.05rem;line-height:1.2;word-break:break-word} +.calendar-stat span{display:block;font-size:.74rem;color:var(--text2);margin-top:5px;line-height:1.45} +.cal-loading{padding:28px 0;text-align:center;color:var(--text2);font-size:.82rem} +.cal-legend{ + display:flex;flex-wrap:wrap;gap:12px;align-items:center;font-size:.72rem;color:var(--text2);margin-bottom:12px; +} +.cal-legend .leg{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:4px;vertical-align:middle} +.cal-legend .leg.high{background:var(--red)} +.cal-legend .leg.medium{background:var(--orange)} +.cal-legend .leg.fed{background:var(--purple)} +.cal-legend .leg.deriv{background:#a8386c} +.cal-legend .leg.cb{background:#2367c7} +.cal-legend .leg.earn{background:var(--blue)} +.cal-legend-note{margin-left:auto} +.cal-board{display:flex;flex-direction:column;gap:16px;margin-bottom:14px;width:100%} +.cal-month{ + width:100%;min-width:0;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:14px;box-shadow:var(--shadow); +} +.cal-month-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:10px} +.cal-month-head h3{font-size:.95rem;line-height:1.2;margin:0} +.cal-month-head span{font-size:.72rem;color:var(--text2)} +.cal-weekdays{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin-bottom:6px} +.cal-weekdays span{text-align:center;font-size:.68rem;color:var(--text2);font-weight:700;padding:4px 0} +.cal-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;width:100%} +.cal-cell{ + min-height:92px;min-width:0;border:1px solid var(--border);border-radius:10px;background:#f9faf7;padding:6px; + display:flex;flex-direction:column;gap:4px;text-align:left;font:inherit;color:inherit; +} +.cal-cell.pad,.cal-cell.off{opacity:.35;background:#fbfcfa;pointer-events:none} +.cal-cell.off .cal-day{color:var(--text2)} +.cal-cell[data-date]{cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s} +.cal-cell[data-date]:hover{border-color:rgba(35,103,199,.35);background:#f5f8ff} +.cal-cell.today{border-color:var(--blue);box-shadow:inset 0 0 0 1px rgba(35,103,199,.25);background:rgba(35,103,199,.05)} +.cal-cell.selected{box-shadow:inset 0 0 0 2px var(--blue);background:rgba(35,103,199,.08)} +.cal-cell.has-hot{border-color:rgba(216,79,69,.35)} +.cal-day-top{display:flex;align-items:center;justify-content:space-between;gap:4px} +.cal-day{font-size:.78rem;font-weight:850;line-height:1} +.cal-count{font-size:.62rem;color:var(--text2);background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:1px 5px;flex-shrink:0} +.cal-events{display:flex;flex-direction:column;gap:3px;min-width:0;overflow:hidden;pointer-events:none} +.cal-ev{ + display:block;width:100%;max-width:100%;font-size:.62rem;line-height:1.25;padding:3px 5px;border-radius:6px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:700;border:0;text-align:left; + font-family:inherit;cursor:default; +} +.cal-ev.high{background:rgba(216,79,69,.14);color:#9a3a32} +.cal-ev.medium{background:rgba(200,138,29,.16);color:#8a5a12} +.cal-ev.low{background:rgba(102,112,100,.12);color:var(--text2)} +.cal-ev.cat-fed{background:rgba(123,87,201,.14);color:#5b3f99} +.cal-ev.cat-earnings{background:rgba(35,103,199,.12);color:var(--blue)} +.cal-ev.cat-derivatives{background:rgba(168,56,108,.14);color:#8a3058} +.cal-ev.cat-market{background:rgba(102,112,100,.14);color:var(--text2)} +.cal-ev.cat-central_bank{background:rgba(35,103,199,.1);color:#1e4f8f} +.cal-more,.cal-quiet{font-size:.6rem;color:var(--text2)} +body.cal-modal-open{overflow:hidden} +.cal-modal{ + position:fixed;inset:0;z-index:450;display:flex;align-items:center;justify-content:center;padding:20px; +} +.cal-modal[hidden]{display:none} +.cal-modal-backdrop{ + position:absolute;inset:0;background:rgba(0,0,0,.35);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px); +} +.cal-modal-panel{ + position:relative;z-index:1;width:min(540px,100%);max-height:min(82vh,680px);overflow:auto; + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:16px 18px; + box-shadow:0 24px 80px rgba(0,0,0,.18);animation:fadeInUp .2s ease both; +} +.cal-day-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap} +.cal-day-detail-head b{font-size:.98rem} +.cal-day-detail-head span{font-size:.74rem;color:var(--text2);margin-right:auto} +.cal-day-close{ + margin-left:auto;border:1px solid var(--border);background:#f9faf7;color:var(--text2); + width:30px;height:30px;border-radius:8px;cursor:pointer;font-size:.9rem;flex-shrink:0; +} +.cal-day-close:hover{border-color:var(--red);color:var(--red)} +.cal-detail-list{display:grid;gap:8px} +.cal-detail-row{ + display:grid;grid-template-columns:minmax(0,1fr) 88px;gap:10px;align-items:start; + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:11px 12px; +} +.cal-detail-row.high{border-left:5px solid var(--red)} +.cal-detail-row.medium{border-left:5px solid var(--orange)} +.cal-detail-row.low{border-left:5px solid var(--text2)} +.cal-detail-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:.86rem} +.cal-detail-title b{font-size:.88rem} +.cal-detail-note{font-size:.72rem;color:var(--text2);line-height:1.45;margin-top:4px} +.cal-detail-meta{text-align:right;font-size:.74rem;font-weight:800} +.cal-detail-meta small{display:block;font-size:.68rem;color:var(--text2);margin-top:3px;font-weight:500} +.event-impact{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;font-size:.68rem;font-weight:900;color:#fff;flex-shrink:0} +.event-impact.high{background:var(--red)} +.event-impact.medium{background:var(--orange)} +.event-impact.low{background:var(--text2)} +.event-symbol{font-size:.68rem;color:var(--blue);font-weight:850;background:rgba(35,103,199,.08);border-radius:999px;padding:3px 7px} +.stock-detail-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.8fr);gap:14px;align-items:start} +.company-profile{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow); +} +.profile-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;border-bottom:1px solid var(--border);padding-bottom:12px;margin-bottom:12px} +.profile-head b{display:block;font-size:.95rem;line-height:1.3} +.profile-head span{display:block;font-size:.72rem;color:var(--text2);margin-top:4px} +.profile-price{font-size:1.2rem;font-weight:900;color:var(--blue);white-space:nowrap} +.profile-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px} +.profile-stats div{background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:9px} +.profile-stats span{display:block;font-size:.68rem;color:var(--text2);margin-bottom:4px} +.profile-stats b{font-size:.76rem;line-height:1.3} +.profile-desc{font-size:.76rem;line-height:1.62;color:var(--text2);margin:10px 0} +.profile-meta{display:grid;gap:6px;font-size:.72rem;color:var(--text2);line-height:1.45} +.profile-meta a{font-weight:800} +.profile-events{display:grid;gap:6px;margin-top:12px;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px} +.profile-events b{font-size:.74rem} +.profile-events span{font-size:.72rem;color:var(--text2);line-height:1.4} +.company-intel{display:grid;gap:14px;margin-top:14px} +.intel-section{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow); +} +.chain-map{display:grid;grid-template-columns:1fr .8fr 1fr;gap:10px;align-items:stretch} +.chain-map div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;display:grid;gap:7px} +.chain-map b{font-size:.82rem} +.chain-map span{font-size:.72rem;color:var(--text2);line-height:1.35} +.chain-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} +.chain-links a,.peer-chips button{ + border:1px solid var(--border);background:#fbfcfa;color:var(--blue);border-radius:999px; + padding:6px 10px;font-size:.72rem;font-weight:800;cursor:pointer; +} +.peer-chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} +.officer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px} +.officer-card{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px} +.officer-card b{display:block;font-size:.82rem} +.officer-card span{display:block;font-size:.72rem;color:var(--text2);line-height:1.4;margin-top:5px} +.officer-card small{display:block;font-size:.68rem;color:var(--text2);margin-top:8px} +.insider-list{display:grid;gap:8px} +.insider-summary{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:10px} +.insider-summary div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px} +.insider-summary b{display:block;font-size:1.25rem} +.insider-summary span{font-size:.72rem;color:var(--text2)} +.insider-summary .good b{color:var(--green)} +.insider-summary .bad b{color:var(--red)} +.insider-row{ + display:grid;grid-template-columns:1fr 110px 130px;gap:10px;align-items:center; + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:11px 12px;color:var(--text); +} +.insider-row.good{border-left:5px solid var(--green)} +.insider-row.bad{border-left:5px solid var(--red)} +.insider-row.warn{border-left:5px solid var(--orange)} +.insider-row b{display:block;font-size:.8rem} +.insider-row span{display:block;font-size:.68rem;color:var(--text2);margin-top:3px;line-height:1.35} +.news-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px} +.news-card{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;color:var(--text)} +.news-card:hover{border-color:rgba(35,103,199,.28)} +.news-card b{display:block;font-size:.82rem;line-height:1.35} +.news-card span{display:block;font-size:.68rem;color:var(--text2);margin-top:7px} +.news-card p{font-size:.72rem;color:var(--text2);line-height:1.5;margin-top:8px} +@media(max-width:520px){ + .metric-grid{grid-template-columns:1fr 1fr} + .metric-card{min-height:116px} + .decision-cols{grid-template-columns:1fr} + .strategy-row{grid-template-columns:1fr} + .calendar-hero{display:block} + .calendar-watch-head{align-items:stretch;flex-direction:column} + .calendar-watch-add{width:100%;min-width:0;justify-content:stretch} + .calendar-watch-add input{max-width:none} + .calendar-summary{grid-template-columns:1fr} + .cal-legend-note{margin-left:0;width:100%} + .cal-cell{min-height:78px} + .cal-detail-row{grid-template-columns:1fr} + .cal-detail-meta{text-align:left;margin-top:6px} + .stock-detail-layout{grid-template-columns:1fr} + .chain-map{grid-template-columns:1fr} + .insider-row{grid-template-columns:1fr} +} + .chart-wrap{ position:relative;width:100%;background:var(--surface); border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow); diff --git a/app.js b/app.js index a230ce6..ebb4550 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,5 @@ // ═══════════════════════════════════════════════════════════ -// Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤 +// MacroScope — 學習教材 / 財報健檢 / 交易復盤 // 本檔在 index.html 的內聯 script 之後載入,可使用其全域函式 // (lineChart、HEX、cssVar…),並負責主視圖切換與三個新分頁。 // ═══════════════════════════════════════════════════════════ @@ -95,7 +95,7 @@ function wlinkHTML(inner) { let [target, display] = inner.split('|'); target = (target || '').trim(); display = (display || '').trim(); - if (!display) display = target.includes('#') ? target.split('#').pop() : target.split('/').pop(); + if (!display) display = deEmmyText(target.includes('#') ? target.split('#').pop() : target.split('/').pop()); return '' + escapeHtml(display) + ''; } function splitRow(line) { @@ -129,7 +129,7 @@ function renderListBlock(lines) { return emit(root); } function renderMarkdown(md) { - md = String(md || '').replace(/\r\n/g, '\n'); + md = deEmmyText(String(md || '').replace(/\r\n/g, '\n')); const fences = []; const fenceLangs = []; md = md.replace(/```[\s\S]*?```/g, (m) => { @@ -170,6 +170,9 @@ function renderMarkdown(md) { } return html; } +function deEmmyText(s) { + return (window.LearnUI && LearnUI.deEmmy) ? LearnUI.deEmmy(s) : String(s || ''); +} // 把容器內所有 [[wikilink]] 綁定成站內跳轉;無法解析的標成 dead function bindWlinks(container) { $$('.wlink[data-link]', container).forEach(elx => { @@ -183,13 +186,14 @@ function bindWlinks(container) { // ═══════════════════════════════════════════════════════════ // 主視圖路由 // ═══════════════════════════════════════════════════════════ -const VIEW_IDS = ['macro', 'learn', 'stock', 'journal']; +const VIEW_IDS = ['macro', 'calendar', 'learn', 'stock', 'journal']; const inited = {}; function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; } function setView(view) { document.body.dataset.view = view; VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; }); $$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); + if (view === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); } if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); } if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); } if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); } @@ -240,31 +244,428 @@ function findLocalNote(kind, id) { } function renderNote(note) { const content = $('#learnContent'); - const fm = note.frontmatter || {}; LEARN.currentNote = note; - let tags = ''; - if (fm.ticker) tags += `代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}`; - if (fm.sector) tags += `${escapeHtml(fm.sector)}`; - if (fm.category) tags += `${escapeHtml(fm.category)}`; - if (fm.date) tags += `${escapeHtml(fm.date)}`; - if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `別名 ${escapeHtml(fm.aliases.join(' · '))}`; const kind = note.kind || LEARN.noteKind; const center = (kind && note.id) ? `${kind}:${note.id}` : ''; - content.innerHTML = - `
- ← 返回 - ${center ? '' : ''} -
` + - (tags ? `
${tags}
` : '') + - `
${renderMarkdown(note.body || '')}
`; + content.innerHTML = LearnUI.renderArticle(note, { + escapeHtml, + renderMarkdown, + linkMap: KB.linkMap, + principles: KB.principles, + }); bindWlinks(content); + LearnUI.bindArticle(content, { + onBack: () => showSection(LEARN.lastSection || 'overview'), + onGraph: () => showGraph({ center, depth: 2 }), + openNote, + goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; }, + }); renderMermaid(content); - $('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview')); - const gb = $('#noteGraphBtn'); - if (gb) gb.addEventListener('click', () => showGraph({ center, depth: 2 })); + bindTermTips(content); window.scrollTo({ top: 0 }); } +// ═══════════════════════════════════════════════════════════ +// 重大事件日曆(網格 · 可增減追蹤 · 今天起兩個月) +// ═══════════════════════════════════════════════════════════ +const CAL = { events: [], selectedDate: '' }; +const CAL_WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; + +function loadCalendarSymbols() { + try { + const raw = localStorage.getItem('calendarSymbols'); + if (raw) { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) return [...new Set(arr.map(s => String(s).trim().toUpperCase()).filter(Boolean))]; + } + } catch (_) {} + const legacy = localStorage.getItem('eventSymbols'); + if (legacy && legacy.trim()) { + const arr = legacy.split(',').map(s => s.trim().toUpperCase()).filter(Boolean); + saveCalendarSymbols(arr); + localStorage.removeItem('eventSymbols'); + return arr; + } + return []; +} +async function syncCalendarWatchlistFromServer() { + try { + const d = await api('/api/calendar/watchlist'); + const remote = (d.symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean); + if (remote.length) { + saveCalendarSymbols(remote); + renderWatchlistChips(); + } else { + const local = loadCalendarSymbols(); + if (local.length) await pushCalendarWatchlistToServer(local); + } + } catch (_) {} +} +async function pushCalendarWatchlistToServer(symbols) { + try { + const d = await api('/api/calendar/watchlist', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ symbols: symbols || loadCalendarSymbols() }), + }); + if (Array.isArray(d.symbols)) saveCalendarSymbols(d.symbols); + } catch (_) {} +} +function saveCalendarSymbols(symbols) { + const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30); + localStorage.setItem('calendarSymbols', JSON.stringify(clean)); + return clean; +} +function calendarRangeISO() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const end = new Date(today); + end.setMonth(end.getMonth() + 2); + const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return { start: iso(today), end: iso(end), today: iso(today) }; +} +function showCalendarMsg(text, tone) { + const el = $('#calendarMsg'); + if (!el) return; + el.textContent = text || ''; + el.className = 'calendar-msg' + (tone ? ' ' + tone : ''); + el.hidden = !text; +} +function renderWatchlistChips() { + const box = $('#calendarWatchlist'); + if (!box) return; + const symbols = loadCalendarSymbols(); + box.innerHTML = symbols.length + ? symbols.map(sym => `${escapeHtml(sym)}`).join('') + : '還沒有追蹤。在上方輸入代號,按 Enter 或「加入」。'; +} +function tryAddCalendarSymbol() { + const input = $('#calendarSymAdd'); + if (!input) return; + const sym = input.value.trim().toUpperCase(); + if (!sym) { showCalendarMsg('請先輸入股票代號', 'warn'); return; } + if (!/^[A-Z0-9.\-]{1,12}$/.test(sym)) { + showCalendarMsg('代號格式不正確(1–12 字,可用英數與 . -)', 'bad'); + input.focus(); + return; + } + const cur = loadCalendarSymbols(); + if (cur.includes(sym)) { + showCalendarMsg(`${sym} 已在追蹤清單`, 'warn'); + input.select(); + return; + } + saveCalendarSymbols([...cur, sym]); + input.value = ''; + renderWatchlistChips(); + pushCalendarWatchlistToServer(); + showCalendarMsg(`已加入 ${sym},正在更新財報日…`, 'good'); + refreshCalendarData(true); +} +function removeCalendarSymbol(sym) { + sym = String(sym || '').trim().toUpperCase(); + if (!sym) return; + saveCalendarSymbols(loadCalendarSymbols().filter(s => s !== sym)); + renderWatchlistChips(); + pushCalendarWatchlistToServer(); + showCalendarMsg(`已移除 ${sym}`, 'good'); + refreshCalendarData(true); +} +function bindCalendarViewEvents(view) { + if (!view || view.dataset.calBound) return; + view.dataset.calBound = '1'; + view.addEventListener('click', e => { + const rm = e.target.closest('.watch-chip-x'); + if (rm) { + e.preventDefault(); + e.stopPropagation(); + removeCalendarSymbol(rm.closest('.watch-chip')?.dataset.sym); + return; + } + if (e.target.closest('#calendarSymGo')) { + e.preventDefault(); + tryAddCalendarSymbol(); + return; + } + if (e.target.closest('#calendarRefresh')) return; + if (e.target.closest('.cal-modal-backdrop') || e.target.closest('.cal-day-close')) { + closeCalendarDay(); + return; + } + const cell = e.target.closest('.cal-cell[data-date]'); + if (cell) { openCalendarDay(cell.dataset.date); return; } + }); + view.addEventListener('keydown', e => { + if (e.key === 'Escape') { closeCalendarDay(); return; } + if (e.target.id === 'calendarSymAdd' && e.key === 'Enter') { + e.preventDefault(); + tryAddCalendarSymbol(); + return; + } + const cell = e.target.closest('.cal-cell[data-date]'); + if (cell && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + openCalendarDay(cell.dataset.date); + } + }); +} +function initCalendar() { + const view = $('#view-calendar'); + view.innerHTML = ` +
+
+
+
市場日曆
+
市場日曆:兩個月內會動到股市的大事
+
不只財報——還有美國通膨、就業、Fed 開會、選擇權結算、各國央行、美股休市。點日期看詳情,標題旁的 ? 有白話說明。
+
+ +
+
+
+
追蹤財報自行新增或刪除,沒有預設清單
+
+ + +
+
+
+ +
+
+ 怎麼看? +

格子裡是當天大事的簡稱;若看到 +3 代表還有更多,點日期可一次看完。每項事件標題旁有 ?,滑鼠移上去有白話解釋(不用懂 ADP、ECB 是什麼也能看)。

+
    +
  • 總經數據:通膨 CPI、非農就業、零售、房市… 公布時常讓大盤晃一下
  • +
  • 聯準會 / 各國央行:決定利率,影響借錢成本與股價估值
  • +
  • 四巫日 / 選擇權到期:衍生品結算,成交量與波動常變大
  • +
  • 財報:只有你自行加入的股票才會顯示
  • +
+
+
+ +
`; + $('#calendarWatchForm').addEventListener('submit', e => { e.preventDefault(); tryAddCalendarSymbol(); }); + $('#calendarRefresh').addEventListener('click', () => refreshCalendarData(true)); + bindCalendarViewEvents(view); + renderWatchlistChips(); + bindTermTips(view); + syncCalendarWatchlistFromServer().finally(() => refreshCalendarData(false)); +} +function calendarEventLabel(ev) { + if (ev.symbol) return ev.symbol; + const t = ev.title || ''; + const rules = [ + [/FOMC.*點陣|SEP/i, 'FOMC+點陣'], + [/FOMC|聯準會.*利率/i, 'FOMC決議'], + [/CPI|消費者物價/i, 'CPI通膨'], + [/非農|Employment Situation/i, '非農就業'], + [/PCE|個人收入/i, 'PCE通膨'], + [/GDP|國內生產/i, 'GDP'], + [/PPI|生產者物價/i, 'PPI'], + [/JOLTS|職缺/i, 'JOLTS職缺'], + [/四巫/i, '四巫日'], + [/月選擇權/i, '選擇權到期'], + [/美股休市/i, '美股休市'], + [/歐洲央行|ECB/i, '歐央行'], + [/日本央行/i, '日央行'], + [/英央行|MPC/i, '英央行'], + [/Jackson Hole/i, '央行年會'], + [/ADP/i, 'ADP就業'], + [/初領失業/i, '失業救濟'], + [/密西根/i, '消費信心'], + [/零售銷售/i, '零售銷售'], + [/工業生產/i, '工業生產'], + [/新屋開工|成屋|營建許可/i, '房市數據'], + [/耐久財/i, '耐久財'], + [/消費信貸/i, '消費信貸'], + [/費城 Fed|製造業指數/i, '製造業調查'], + [/非製造業/i, '服務業調查'], + [/就業成本|ECI/i, '就業成本'], + [/生產力/i, '生產力'], + [/進出口物價/i, '進出口價'], + [/實質薪資/i, '實質薪資'], + [/國際貿易/i, '貿易數據'], + [/財報/i, '財報'], + ]; + for (const [re, label] of rules) if (re.test(t)) return label; + return t.length > 8 ? t.slice(0, 7) + '…' : t; +} +function calendarEventChip(ev) { + const cat = ev.category || 'macro'; + const title = `${ev.title || ''}${ev.time ? ' · ' + ev.time : ''}${ev.note ? '\n' + ev.note : ''}`; + return ``; +} +function calendarDayDetailHTML(date, events) { + if (!date) return ''; + const d = new Date(date + 'T00:00:00'); + const label = isNaN(d) ? date : d.toLocaleDateString('zh-TW', { month: 'long', day: 'numeric', weekday: 'long' }); + if (!events.length) { + return `
+
${escapeHtml(label)}
+
這天沒有事件。
+
`; + } + const rows = events.map(ev => { + const tipKey = eventTipKey(ev.title, ev.note); + const tip = tipKey ? termTipBtn(tipKey, ev.title) : ''; + const cat = { fed: '聯準會', macro: '總經', earnings: '財報', derivatives: '衍生品', market: '市場', central_bank: '央行' }[ev.category] || '事件'; + const impact = { high: '高', medium: '中', low: '低' }[ev.impact] || '低'; + return `
+
+
${impact}${escapeHtml(ev.title)}${tip}${ev.symbol ? `${escapeHtml(ev.symbol)}` : ''}
+
${escapeHtml(ev.note || '—')}${ev.time ? ' · ' + escapeHtml(ev.time) : ''}
+
+
${escapeHtml(cat)}${escapeHtml(ev.source || '')}
+
`; + }).join(''); + return `
+
${escapeHtml(label)}${events.length} 項事件
+
${rows}
+
`; +} +function closeCalendarDay() { + CAL.selectedDate = ''; + $$('.cal-cell.selected').forEach(el => el.classList.remove('selected')); + const modal = $('#calendarModal'); + if (modal) modal.hidden = true; + document.body.classList.remove('cal-modal-open'); +} +function openCalendarDay(date) { + CAL.selectedDate = date || ''; + $$('.cal-cell.selected').forEach(el => el.classList.remove('selected')); + const cell = $(`.cal-cell[data-date="${date}"]`); + if (cell) cell.classList.add('selected'); + const events = CAL.events.filter(ev => ev.date === date); + const modal = $('#calendarModal'); + const panel = $('#calendarModalPanel'); + if (!modal || !panel) return; + panel.innerHTML = calendarDayDetailHTML(date, events); + bindTermTips(panel); + modal.hidden = false; + document.body.classList.add('cal-modal-open'); + $('.cal-day-close', panel)?.focus(); +} +function buildCalendarGrid(events, range) { + const byDate = new Map(); + for (const ev of events) { + if (ev.date < range.start || ev.date > range.end) continue; + if (!byDate.has(ev.date)) byDate.set(ev.date, []); + byDate.get(ev.date).push(ev); + } + for (const [, list] of byDate) { + list.sort((a, b) => { + const rank = { high: 0, medium: 1, low: 2 }; + const ra = rank[a.impact] ?? 2, rb = rank[b.impact] ?? 2; + if (ra !== rb) return ra - rb; + return String(a.title).localeCompare(String(b.title)); + }); + } + const start = new Date(range.start + 'T00:00:00'); + const end = new Date(range.end + 'T00:00:00'); + const months = []; + let cursor = new Date(start.getFullYear(), start.getMonth(), 1); + while (cursor <= end) { + months.push(new Date(cursor)); + cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); + } + const monthHTML = months.map((m, idx) => { + const y = m.getFullYear(), mo = m.getMonth(); + const firstDow = new Date(y, mo, 1).getDay(); + const daysInMonth = new Date(y, mo + 1, 0).getDate(); + let cells = ''; + for (let i = 0; i < firstDow; i++) cells += '
'; + for (let day = 1; day <= daysInMonth; day++) { + const iso = `${y}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (iso < range.start || iso > range.end) { + cells += `
${day}
`; + continue; + } + const dayEvents = byDate.get(iso) || []; + const cls = [ + 'cal-cell', + iso === range.today ? 'today' : '', + dayEvents.length ? 'has-events' : '', + dayEvents.some(e => e.impact === 'high') ? 'has-hot' : '', + ].filter(Boolean).join(' '); + const evHtml = dayEvents.slice(0, 6).map(calendarEventChip).join('') + + (dayEvents.length > 6 ? `+${dayEvents.length - 6} 更多` : ''); + cells += `
+
${day}${dayEvents.length ? `${dayEvents.length}` : ''}
+
${evHtml || ''}
+
`; + } + const title = m.toLocaleDateString('zh-TW', { year: 'numeric', month: 'long' }); + return `
+

${escapeHtml(title)}

${idx === 0 ? '從今天起' : ''}
+
${CAL_WEEKDAYS.map(w => `${w}`).join('')}
+
${cells}
+
`; + }).join(''); + return `
${monthHTML}
`; +} +function formatCalendarCachedAt(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return ''; + return d.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} +async function refreshCalendarData(force) { + const body = $('#calendarBody'); + if (!body) return; + const range = calendarRangeISO(); + const symbols = loadCalendarSymbols(); + const hadEvents = CAL.events.length > 0; + if (!hadEvents || force) { + body.innerHTML = ` +
+
區間內事件(自動)
+
${escapeHtml(range.start)}起算日(今天)
+
${escapeHtml(range.end)}結束日(約兩個月)
+
${symbols.length}你追蹤的財報
+
+
+ 高衝擊 + + 聯準會 + 四巫 / 選擇權 + 全球央行 + 財報(自訂) + 點日期 → 彈窗看完整列表與 ? 說明 +
+
正在載入日曆…
+
+
`; + } else { + const note = $('#calendarSourceNote'); + if (note) note.textContent = '正在背景更新日曆…'; + } + try { + closeCalendarDay(); + const qs = new URLSearchParams({ symbols: symbols.join(','), start: range.start, end: range.end }); + if (force) qs.set('fresh', '1'); + const d = await api('/api/calendar?' + qs.toString()); + CAL.events = (d.events || []).filter(ev => ev.date >= range.start && ev.date <= range.end); + const autoCount = CAL.events.filter(ev => ev.category !== 'earnings').length; + const countEl = $('#calendarEventCount'); + if (countEl) countEl.textContent = String(autoCount); + const sourceNote = (d.sources || []).map(s => `${s.ok ? '已更新' : '待補'} ${s.name}`).join(' · '); + const loading = $('.cal-loading', body); + if (loading) loading.remove(); + $('#calendarGridHost').innerHTML = buildCalendarGrid(CAL.events, range); + const staleHint = d.stale ? '(更新失敗,顯示資料庫舊資料)' : (d.cached ? '(資料庫快取,每日更新)' : '(剛更新)'); + const timeHint = formatCalendarCachedAt(d.cachedAt) ? ` · 更新 ${formatCalendarCachedAt(d.cachedAt)}` : ''; + $('#calendarSourceNote').textContent = `共 ${CAL.events.length} 項${staleHint}${timeHint}。來源:${sourceNote}。`; + if (CAL.selectedDate) openCalendarDay(CAL.selectedDate); + showCalendarMsg('', ''); + } catch (e) { + body.innerHTML = `
無法更新日曆:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; + } +} + // ═══════════════════════════════════════════════════════════ // 學習教材視圖 // ═══════════════════════════════════════════════════════════ @@ -287,20 +688,28 @@ async function initLearn() { const c = KB.counts || {}; view.innerHTML = `
-
-
📚 學習教材
-
把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 紫色連結 都能跳到對應筆記。
+
+
+
學習路徑
+
照問題學,不要硬背名詞
+
從「現在大環境如何」「這家公司值不值得研究」「這筆交易哪裡做錯」三條路開始。每步都有連結與可點的工具,案例會標出可重複使用的原則。
+
+
+
${(KB.cases || []).length}案例講解
+
${(KB.principles || []).length}投資原則
+
${c.terms || 0}名詞速查
+
課程
- 課綱總覽 - 心法地圖 + 今日入口 + 原則地圖 練習題庫
內容
學習分類 ${(KB.categories || []).length} 案例講解 ${(KB.cases || []).length} - 投資心法 ${(KB.principles || []).length} + 投資原則 ${(KB.principles || []).length}
視覺化
🔗 知識圖譜
速查
@@ -320,7 +729,7 @@ function showSection(section) { setLearnActive(section); const content = $('#learnContent'); if (!content) return; - if (section === 'overview') return renderNote(Object.assign({ kind: 'overview' }, KB.overview || { body: '# 課綱總覽\n(尚無內容)' })); + if (section === 'overview') return renderLearnHome(); if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n(尚無內容)' })); if (section === 'quiz') return renderQuiz(); if (section === 'graph') return showGraph(); @@ -329,27 +738,41 @@ function showSection(section) { if (section === 'principles') return renderPrincipleList(); if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section); } +function renderLearnHome() { + const content = $('#learnContent'); + content.innerHTML = LearnUI.renderHome({ escapeHtml }); + LearnUI.bindHome(content, { + openNote, + showSection, + goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; }, + }); + window.scrollTo({ top: 0 }); +} function renderCardList(title, items, kind) { const content = $('#learnContent'); - const cards = (items || []).map(it => ` + let cards; + if (kind === 'case') { + cards = LearnUI.renderCaseCards(items, escapeHtml, { linkMap: KB.linkMap, principles: KB.principles }); + } else { + cards = (items || []).map(it => `
-
${escapeHtml(it.title)}
- ${it.summary ? `
${escapeHtml(it.summary)}
` : ''} +
${escapeHtml(deEmmyText(it.title))}
+ ${it.summary ? `
${escapeHtml(deEmmyText(it.summary))}
` : ''}
`).join(''); - content.innerHTML = `
${escapeHtml(title)}
${cards || '
尚無內容。
'}
`; + } + const hint = kind === 'case' + ? '

每個案例都會標「可重用原則」——重點不是記住一家公司,而是記住判斷方法。

' + : ''; + content.innerHTML = `
${escapeHtml(title)}
${hint}
${cards || '
尚無內容。
'}
`; $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id))); window.scrollTo({ top: 0 }); } function renderPrincipleList() { const content = $('#learnContent'); - const cards = (KB.principles || []).map(p => ` -
-
${escapeHtml(p.title)}
-
`).join(''); - content.innerHTML = `
Emmy 投資心法
-
共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。
-
${cards}
`; - $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id))); + content.innerHTML = `
投資原則庫
+
共 ${(KB.principles || []).length} 條。已依主題分群;點開可讀白話說明。完整索引見「原則地圖」。
+
${LearnUI.renderPrincipleGroups(KB.principles, escapeHtml)}
`; + $$('.pg-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id))); window.scrollTo({ top: 0 }); } function renderGlossary(section) { @@ -389,7 +812,7 @@ function renderQuiz() { // ── 知識圖譜(vis-network)── let graphNetwork = null; const GRAPH_LEGEND = [ - ['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '心法', '#af52de'], + ['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '原則', '#af52de'], ['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'], ]; async function showGraph(opts = {}) { @@ -519,15 +942,15 @@ function drawLineChart(el, series, opts = {}) { // ═══════════════════════════════════════════════════════════ // 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測) // ═══════════════════════════════════════════════════════════ -const STOCK = { symbol: '', sub: 'price', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null }; -const SUBS = ['price', 'finbox', 'map', 'backtest']; +const STOCK = { symbol: '', sub: 'metrics', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null, fundamentals: {} }; +const SUBS = ['metrics', 'price', 'finbox', 'map', 'backtest']; function initStock() { const view = $('#view-stock'); view.innerHTML = `
📈 個股工具
-
輸入一檔股票代號,所有工具一次到位:價格走勢、財報健檢、用 Emmy 六層漏斗的投資地圖判斷該不該進場、以及策略回測。資料皆會存資料庫快取以節省 API。
+
輸入一檔股票代號,所有工具一次到位:價格走勢、財報健檢、用六層漏斗投資地圖判斷該不該進場、以及策略回測
範例:NVDAAMDMSFTAVGOAAPL
+
@@ -553,15 +978,16 @@ function initStock() { $('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); }); $$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); })); $$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub))); - setSub('map'); // 投資地圖不需代號也能先看判斷流程 + setSub('metrics'); } function setStockSymbol(sym) { sym = (sym || '').trim().toUpperCase(); if (!sym) return; STOCK.symbol = sym; STOCK.rendered = {}; // 換股票 → 各分頁重抓 + STOCK.fundamentals = {}; $('#stkSym').value = sym; - if (STOCK.sub === 'map') setSub('price'); // 輸入代號後預設先看價格 + if (STOCK.sub === 'map') setSub('metrics'); // 輸入代號後預設先看指標面板 else renderSub(STOCK.sub); } function setSub(sub) { @@ -577,12 +1003,428 @@ function needSymbol(pane) { return true; } function renderSub(sub) { + if (sub === 'metrics') return renderMetricsPane(); if (sub === 'price') return renderPrice(); if (sub === 'finbox') return renderFinboxPane(); if (sub === 'map') return renderMap(); if (sub === 'backtest') return renderBacktestPane(); } +// ── 指標面板(市場總覽 / 風險 / 回報 / 效率 / 預測 / 穩健度)── +async function loadFundamentals(sym, fresh) { + sym = (sym || STOCK.symbol || '').trim().toUpperCase(); + if (!fresh && STOCK.fundamentals[sym]) return STOCK.fundamentals[sym]; + const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : '')); + STOCK.fundamentals[sym] = d; + return d; +} +function calcPct(a, b) { return (a != null && b) ? (a / b) * 100 : null; } +function growth(cur, prev) { return (cur != null && prev) ? ((cur - prev) / Math.abs(prev)) * 100 : null; } +function ratio(a, b) { return (a != null && b) ? a / b : null; } +function fmtRatio(v, d = 1) { return v == null || isNaN(v) ? '—' : Number(v).toFixed(d) + 'x'; } +function fmtMetric(v, kind) { + if (v == null || isNaN(v)) return '—'; + if (kind === 'money') return fmtMoney(v); + if (kind === 'pct') return fmtPct(v, 1); + if (kind === 'ratio') return fmtRatio(v, 1); + if (kind === 'num') return fmtNum(v, 2); + if (kind === 'shares') return fmtNum(v / 1e9, 2) + 'B'; + if (kind === 'compact') { + const a = Math.abs(v), s = v < 0 ? '-' : ''; + if (a >= 1e9) return s + fmtNum(a / 1e9, 2) + 'B'; + if (a >= 1e6) return s + fmtNum(a / 1e6, 2) + 'M'; + if (a >= 1e3) return s + fmtNum(a / 1e3, 1) + 'K'; + return fmtNum(v, 0); + } + return String(v); +} +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } +function priceStats(points) { + points = (points || []).filter(p => p.close != null); + if (points.length < 2) return {}; + const last = points[points.length - 1]; + const nearest = (days) => { + const target = new Date(last.date); target.setUTCDate(target.getUTCDate() - days); + let best = points[0], bd = Infinity; + for (const p of points) { + const d = Math.abs(new Date(p.date) - target); + if (d < bd) { bd = d; best = p; } + } + return best; + }; + const ret = days => { + const p = nearest(days); + return p && p.close ? (last.close / p.close - 1) * 100 : null; + }; + const recent = points.slice(-61); + const daily = []; + for (let i = 1; i < recent.length; i++) if (recent[i - 1].close) daily.push(recent[i].close / recent[i - 1].close - 1); + const mean = daily.reduce((a, b) => a + b, 0) / (daily.length || 1); + const variance = daily.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / Math.max(1, daily.length - 1); + let peak = -Infinity, mdd = 0; + for (const p of points) { peak = Math.max(peak, p.close); if (peak > 0) mdd = Math.min(mdd, (p.close / peak - 1) * 100); } + return { ret1m: ret(30), ret3m: ret(91), ret6m: ret(182), ret1y: ret(365), ret3y: ret(1095), ret5y: ret(1825), volatility: Math.sqrt(variance) * Math.sqrt(252) * 100, maxDrawdown: mdd }; +} +function technicalStats(points, quote = {}) { + const clean = (points || []).filter(p => p.close != null).map(p => ({ ...p, close: Number(p.close) })); + if (clean.length < 20) return {}; + const closes = clean.map(p => p.close); + const last = quote.price ?? closes[closes.length - 1]; + const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null; + const ma = (n) => closes.length >= n ? avg(closes.slice(-n)) : null; + const dist = (v) => (last != null && v) ? (last / v - 1) * 100 : null; + const emaSeries = (period) => { + const k = 2 / (period + 1); + const out = []; + let prev = null; + for (let i = 0; i < closes.length; i++) { + prev = prev == null ? closes[i] : closes[i] * k + prev * (1 - k); + out.push(prev); + } + return out; + }; + let rsi14 = null; + if (closes.length > 14) { + let gains = 0, losses = 0; + for (let i = closes.length - 14; i < closes.length; i++) { + const ch = closes[i] - closes[i - 1]; + if (ch >= 0) gains += ch; else losses -= ch; + } + const avgGain = gains / 14, avgLoss = losses / 14; + rsi14 = avgLoss === 0 ? 100 : 100 - (100 / (1 + avgGain / avgLoss)); + } + const ema12 = emaSeries(12), ema26 = emaSeries(26); + const macdSeries = ema12.map((v, i) => v - ema26[i]); + const signal = (() => { + const k = 2 / 10; + let prev = null; + for (const v of macdSeries) prev = prev == null ? v : v * k + prev * (1 - k); + return prev; + })(); + const macd = macdSeries[macdSeries.length - 1]; + const ma20 = ma(20), ma50 = quote.fiftyDayAverage ?? ma(50), ma100 = ma(100), ma200 = quote.twoHundredDayAverage ?? ma(200); + const last20 = closes.slice(-20); + const sd20 = last20.length >= 20 ? Math.sqrt(last20.reduce((a, v) => a + Math.pow(v - ma20, 2), 0) / last20.length) : null; + const bollUpper = ma20 != null && sd20 != null ? ma20 + 2 * sd20 : null; + const bollLower = ma20 != null && sd20 != null ? ma20 - 2 * sd20 : null; + const bollPos = (last != null && bollUpper != null && bollLower != null && bollUpper !== bollLower) ? ((last - bollLower) / (bollUpper - bollLower)) * 100 : null; + const last252 = clean.slice(-252).map(p => p.close); + const high52 = quote.fiftyTwoWeekHigh ?? (last252.length ? Math.max(...last252) : null); + const low52 = quote.fiftyTwoWeekLow ?? (last252.length ? Math.min(...last252) : null); + const pos52 = (last != null && high52 != null && low52 != null && high52 !== low52) ? ((last - low52) / (high52 - low52)) * 100 : null; + const volumeRatio = (quote.volume != null && quote.avgVolume) ? quote.volume / quote.avgVolume : null; + const trendScore = + (last != null && ma20 != null && last > ma20 ? 1 : -1) + + (last != null && ma50 != null && last > ma50 ? 1 : -1) + + (last != null && ma200 != null && last > ma200 ? 1 : -1) + + (ma50 != null && ma200 != null && ma50 > ma200 ? 1 : -1); + return { + last, ma5: ma(5), ma10: ma(10), ma20, ma50, ma100, ma200, + dist20: dist(ma20), dist50: dist(ma50), dist200: dist(ma200), + rsi14, macd, macdSignal: signal, macdHist: macd != null && signal != null ? macd - signal : null, + bollUpper, bollLower, bollPos, high52, low52, pos52, volumeRatio, trendScore, + }; +} +function metricStatus(v, good, warn, invert) { + if (v == null || isNaN(v)) return 'na'; + if (invert) return v <= good ? 'good' : v <= warn ? 'warn' : 'bad'; + return v >= good ? 'good' : v >= warn ? 'warn' : 'bad'; +} +function metricCard(m) { + const cls = m.missing ? 'missing' : (m.status || 'na'); + const tip = m.tipKey ? termTipBtn(m.tipKey, m.label) : ''; + return `
+
${escapeHtml(m.label)}${tip}
+
${m.missing ? '等待免費來源' : escapeHtml(m.value)}
+
${escapeHtml(m.note || '')}
+
`; +} +function metricSection(title, subtitle, items, sectionTipKey) { + const headTip = sectionTipKey ? termTipBtn(sectionTipKey, title) : ''; + return `
+

${escapeHtml(title)}${headTip}

${escapeHtml(subtitle)}
+
${items.map(metricCard).join('')}
+
`; +} +function formulaBlock(title, formula, note, tipKey) { + const tip = tipKey ? termTipBtn(tipKey, title) : ''; + return `
+
${escapeHtml(title)}${tip}
+
${escapeHtml(formula)}
+
${escapeHtml(note || '')}
+
`; +} +function dcfValue({ fcf, cash, debt, shares, price, revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility }) { + if (!(fcf > 0) || !(shares > 0)) return null; + const growthInputs = [revGrowth, netGrowth].filter(v => v != null && isFinite(v)); + let growth = growthInputs.length ? growthInputs.reduce((a, b) => a + b, 0) / growthInputs.length : 6; + if (grossMargin >= 55) growth += 2; + if (roe >= 25) growth += 2; + growth = clamp(growth, -5, 25); + const terminalGrowth = 2.5; + let discount = 9; + if (volatility > 35) discount += 1; + if (volatility > 55) discount += 1; + if (debtEquity > 1) discount += 1; + discount = clamp(discount, 8, 13); + const run = (g, dr) => { + const tg = terminalGrowth / 100; + const r = dr / 100; + let pv = 0, cur = fcf; + for (let year = 1; year <= 5; year++) { + const fade = (6 - year) / 5; + const yrGrowth = (tg + ((g / 100) - tg) * fade); + cur *= (1 + yrGrowth); + pv += cur / Math.pow(1 + r, year); + } + const terminal = (cur * (1 + tg)) / Math.max(0.01, r - tg); + const equityValue = pv + terminal + (cash || 0) - (debt || 0); + return equityValue / shares; + }; + const fair = run(growth, discount); + const low = run(clamp(growth - 4, -8, 22), discount + 1); + const high = run(clamp(growth + 4, -2, 30), Math.max(7, discount - 1)); + return { + fair, low, high, growth, discount, terminalGrowth, + upside: price ? ((fair / price) - 1) * 100 : null, + }; +} +function decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests }) { + let score = 0; + const reasons = []; + const cautions = []; + const actions = []; + if (fair?.upside != null) { + if (fair.upside >= 20) { score += 2; reasons.push(`DCF 顯示安全邊際 ${fmtPct(fair.upside, 1)},估值有折價。`); } + else if (fair.upside >= 0) { score += 1; reasons.push(`DCF 公允價值略高於現價,估值不算貴。`); } + else if (fair.upside <= -25) { score -= 2; cautions.push(`DCF 顯示現價高於公允價值 ${fmtPct(Math.abs(fair.upside), 1)},追價風險高。`); } + else { score -= 1; cautions.push(`DCF 略低於現價,估值需要更高成長兌現。`); } + } + if (d.targetPrice && d.price) { + const targetUpside = (d.targetPrice / d.price - 1) * 100; + if (targetUpside >= 15) { score += 1; reasons.push(`公開目標價仍有 ${fmtPct(targetUpside, 1)} 空間。`); } + else if (targetUpside < 0) { score -= 1; cautions.push('公開目標價低於現價,市場共識不支持追高。'); } + } + if ((px.ret6m ?? 0) > 10 && (px.ret1y ?? 0) > 0) { score += 1; reasons.push('中長期價格趨勢仍向上。'); } + if ((px.ret3m ?? 0) < -10 || (px.ret6m ?? 0) < -15) { score -= 1; cautions.push('近期價格動能轉弱,先等趨勢修復。'); } + if (tech?.trendScore >= 3) { score += 1; reasons.push('現價站上主要均線,MA50 也高於 MA200,技術趨勢偏多。'); } + else if (tech?.dist200 != null && tech.dist200 < -3) { score -= 1; cautions.push(`現價低於 MA200 ${fmtPct(Math.abs(tech.dist200), 1)},長線趨勢仍需修復。`); } + if (tech?.rsi14 != null && tech.rsi14 >= 75) { score -= 1; cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)},短線偏熱,追價要保守。`); } + else if (tech?.rsi14 != null && tech.rsi14 <= 30) { cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)},可能超賣,但需要價格止穩確認。`); } + if ((grossMargin ?? 0) >= 50 && (roe ?? 0) >= 15) { score += 1; reasons.push('毛利率與 ROE 反映品質不錯。'); } + if ((revGrowth ?? 0) >= 15 || (netGrowth ?? 0) >= 10) { score += 1; reasons.push('營收/淨利成長仍有支撐。'); } + if ((px.volatility ?? 0) > 45) { score -= 1; cautions.push('波動偏高,部位要小或分批。'); } + if ((debtEquity ?? 0) > 1.5) { score -= 1; cautions.push('槓桿偏高,利率或景氣逆風時要更保守。'); } + const smaBt = backtests?.find(b => b.strategy === 'sma'); + if (smaBt?.stats && smaBt?.benchStats) { + if (smaBt.stats.cagr > 0 && smaBt.stats.maxDrawdown < smaBt.benchStats.maxDrawdown) { + score += 1; reasons.push('均線策略歷史上降低回撤,適合用趨勢訊號控風險。'); + } else if (smaBt.stats.cagr < 0) { + score -= 1; cautions.push('均線策略回測不佳,機械追趨勢容易被洗。'); + } + } + let label = '觀望', cls = 'na'; + if (score >= 4) { label = '偏買 / 可分批'; cls = 'good'; actions.push('可考慮分批建立部位,並用回測策略當進出場規則。'); } + else if (score >= 2) { label = '偏多觀望'; cls = 'good'; actions.push('基本面或趨勢有支撐,但建議等技術訊號或回檔再加。'); } + else if (score >= 0) { label = '觀望 / 等訊號'; cls = 'warn'; actions.push('不急著買,先等估值、趨勢或財報其中一項轉強。'); } + else { label = '減碼 / 避免追高'; cls = 'bad'; actions.push('若已持有,偏向設移動停利或減碼;新倉等更好的價格或訊號。'); } + if (!reasons.length) reasons.push('目前可用資料還不足以形成強烈買進理由。'); + if (!cautions.length) cautions.push('仍需留意財報公布、總經環境與單日大幅波動。'); + return { label, cls, score, reasons, cautions, actions }; +} +function decisionHTML(summary) { + return `
+
行動提醒
+
${escapeHtml(summary.label)}
+
綜合分數 ${summary.score >= 0 ? '+' : ''}${summary.score}
+
+
支持理由${summary.reasons.map(x => `

${escapeHtml(x)}

`).join('')}
+
小提醒${summary.cautions.map(x => `

${escapeHtml(x)}

`).join('')}
+
操作方式${summary.actions.map(x => `

${escapeHtml(x)}

`).join('')}
+
+
`; +} +function strategySummaryHTML(backtests) { + if (!Array.isArray(backtests) || !backtests.length) return ''; + const rows = backtests.map(b => ` +
+
${escapeHtml(b.strategyLabel)}${escapeHtml(b.formula || '')}
+
${b.stats ? (b.stats.cagr >= 0 ? '+' : '') + b.stats.cagr.toFixed(1) + '% CAGR' + termTipBtn('cagr', 'CAGR') : '—'}
+
${b.stats ? b.stats.maxDrawdown.toFixed(1) + '% 回撤' + termTipBtn('max_drawdown', '最大回撤') : '—'}
+
`).join(''); + return `
+

策略回測摘要

用歷史資料輔助判斷,不代表未來
+
${rows}
+
+ ${formulaBlock('均線趨勢', 'fast = sma(close, 50)\nslow = sma(close, 200)\ninMarket = fast > slow', '短均線在長均線之上才持有,跌破就空手。', 'sma_strategy')} + ${formulaBlock('回落買進', 'peak = highest(close)\ndrawdown = close / peak - 1\nbuy = drawdown <= -15%', '等待從高點回落到設定幅度再進場。', 'dip_strategy')} + ${formulaBlock('分批投入', 'if monthChanged\n buy(monthlyAmount)', '不猜低點,每月固定投入,適合降低進場時點壓力。', 'dca_strategy')} +
+
`; +} +function quoteFreshLabel(q) { + if (!q?.marketTime && !q?._fetchedAt) return ''; + const t = q.marketTime ? new Date(q.marketTime) : new Date(q._fetchedAt); + if (isNaN(t)) return ''; + return t.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} +function buildMetrics(d, px, tech, quote, backtests) { + const q = d.quarters || [], a = d.annual || [], bal = d.balance || {}; + const curQ = q[0] || {}, prevQ = q[1] || {}, yaQ = q[4] || null; + const curY = a[0] || {}, prevY = a[1] || {}; + const equity = bal.totalEquity != null ? bal.totalEquity : ((bal.totalAssets != null && bal.totalLiabilities != null) ? bal.totalAssets - bal.totalLiabilities : null); + const ev = d.marketCap != null ? d.marketCap + (bal.totalDebt || 0) - (bal.cash || 0) : null; + const fcf = curY.ocf != null && curY.capex != null ? curY.ocf + curY.capex : null; + const shares = d.sharesOutstanding ?? ((d.marketCap != null && d.price) ? d.marketCap / d.price : null); + const pe = d.peTrailing ?? ((d.marketCap != null && curY.netIncome > 0) ? d.marketCap / curY.netIncome : null); + const revGrowth = yaQ ? growth(curQ.revenue, yaQ.revenue) : growth(curY.revenue, prevY.revenue); + const netGrowth = yaQ ? growth(curQ.netIncome, yaQ.netIncome) : growth(curY.netIncome, prevY.netIncome); + const opMargin = curQ.operatingMargin ?? null; + const grossMargin = curQ.grossMargin ?? curY.grossMargin ?? null; + const netMargin = curQ.netMargin ?? curY.netMargin ?? null; + const roa = calcPct(curY.netIncome, bal.totalAssets); + const roe = calcPct(curY.netIncome, equity); + const debtEquity = ratio(bal.totalLiabilities, equity); + const currentRatio = ratio(bal.currentAssets, bal.currentLiabilities); + const cashDebt = ratio(bal.cash, bal.totalDebt); + const fair = dcfValue({ + fcf, cash: bal.cash, debt: bal.totalDebt, shares, price: d.price, + revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility: px.volatility, + }); + const summary = decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests }); + const changeText = quote?.changePercent != null + ? `${quote.change >= 0 ? '+' : ''}${fmtNum(quote.change, 2)} / ${quote.changePercent >= 0 ? '+' : ''}${fmtPct(quote.changePercent, 2)}` + : '—'; + const rsiStatus = tech.rsi14 == null ? 'na' : tech.rsi14 >= 75 ? 'bad' : tech.rsi14 >= 65 ? 'warn' : tech.rsi14 <= 30 ? 'warn' : 'good'; + const macdStatus = tech.macdHist == null ? 'na' : tech.macdHist >= 0 ? 'good' : 'bad'; + const bollStatus = tech.bollPos == null ? 'na' : tech.bollPos > 95 ? 'bad' : tech.bollPos > 80 ? 'warn' : tech.bollPos < 5 ? 'warn' : 'good'; + const pos52Status = tech.pos52 == null ? 'na' : tech.pos52 > 92 ? 'warn' : tech.pos52 < 25 ? 'bad' : 'good'; + return [ + decisionHTML(summary), + metricSection('市場總覽', '價格、估值與公司規模', [ + { label: '現價', value: fmtNum(d.price, 2), status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: `${d.currency || ''}${quoteFreshLabel(quote) ? ' · ' + quoteFreshLabel(quote) : ''}` }, + { label: '今日漲跌', value: changeText, status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: quote?.source || '免費報價來源' }, + { label: '市值', tipKey: 'market_cap', value: fmtMoney(d.marketCap), status: 'na', note: 'Yahoo/價格資料' }, + { label: '企業價值 EV', tipKey: 'ev', value: fmtMoney(ev), status: 'na', note: '市值 + 債務 - 現金' }, + { label: '市盈率 P/E', tipKey: 'pe', value: fmtRatio(pe, 1), status: metricStatus(pe, 25, 45, true), note: d.peTrailing != null ? 'Yahoo trailing PE' : '市值 / 年度淨利估算' }, + { label: '流通股數', tipKey: 'shares', value: fmtMetric(shares, 'shares'), status: 'na', note: d.sharesOutstanding != null ? '來源揭露' : '市值 / 現價估算' }, + { label: '股息殖利率', tipKey: 'dividend_yield', value: fmtPct(d.dividendYield, 2), status: 'na', note: 'Nasdaq summary' }, + { label: '營收成長', tipKey: 'rev_growth', value: fmtPct(revGrowth, 1), status: metricStatus(revGrowth, 15, 0), note: yaQ ? '最近季年增' : '年度年增' }, + ], 'section_market'), + metricSection('技術面', 'MA、RSI、MACD、量能與價格位置', [ + { label: 'MA20 / MA50', tipKey: 'ma', value: `${fmtNum(tech.ma20, 2)} / ${fmtNum(tech.ma50, 2)}`, status: metricStatus(tech.dist50, 0, -5), note: `距 MA50 ${fmtPct(tech.dist50, 1)}` }, + { label: 'MA100 / MA200', tipKey: 'ma', value: `${fmtNum(tech.ma100, 2)} / ${fmtNum(tech.ma200, 2)}`, status: metricStatus(tech.dist200, 0, -8), note: `距 MA200 ${fmtPct(tech.dist200, 1)}` }, + { label: 'RSI(14)', tipKey: 'rsi', value: fmtNum(tech.rsi14, 1), status: rsiStatus, note: tech.rsi14 >= 70 ? '偏熱' : tech.rsi14 <= 30 ? '偏弱/超賣' : '中性區間' }, + { label: 'MACD(12,26,9)', tipKey: 'macd', value: fmtNum(tech.macdHist, 2), status: macdStatus, note: tech.macdHist >= 0 ? '柱狀體偏多' : '柱狀體偏空' }, + { label: '布林位置', tipKey: 'boll', value: fmtPct(tech.bollPos, 0), status: bollStatus, note: `下緣 ${fmtNum(tech.bollLower, 2)} / 上緣 ${fmtNum(tech.bollUpper, 2)}` }, + { label: '52週位置', tipKey: 'pos52', value: fmtPct(tech.pos52, 0), status: pos52Status, note: `${fmtNum(tech.low52, 2)} ~ ${fmtNum(tech.high52, 2)}` }, + { label: '成交量 / 均量', tipKey: 'volume_ratio', value: fmtRatio(tech.volumeRatio, 2), status: metricStatus(tech.volumeRatio, 1.2, .7), note: quote?.volume != null ? `今日量 ${fmtMetric(quote.volume, 'compact')}` : '等待成交量' }, + { label: '日內高低', value: `${fmtNum(quote?.dayLow, 2)} ~ ${fmtNum(quote?.dayHigh, 2)}`, status: 'na', note: quote?.previousClose != null ? `昨收 ${fmtNum(quote.previousClose, 2)}` : '' }, + ], 'section_technical'), + metricSection('風險', '財務壓力與股價波動', [ + { label: '總負債 / 總資產', tipKey: 'debt_assets', value: fmtPct(bal.debtToAssets, 1), status: metricStatus(bal.debtToAssets, 50, 70, true), note: '越低越穩' }, + { label: '負債股本比', tipKey: 'debt_equity', value: fmtRatio(debtEquity, 1), status: metricStatus(debtEquity, 1, 2, true), note: '總負債 / 股東權益' }, + { label: '流動比率', tipKey: 'current_ratio', value: fmtRatio(currentRatio, 1), status: metricStatus(currentRatio, 1.5, 1), note: '流動資產 / 流動負債' }, + { label: '現金 / 債務', tipKey: 'cash_debt', value: fmtRatio(cashDebt, 1), status: metricStatus(cashDebt, 1, .3), note: '償債緩衝' }, + { label: '60日年化波動', tipKey: 'volatility', value: fmtPct(px.volatility, 1), status: metricStatus(px.volatility, 25, 45, true), note: '由日報酬估算' }, + { label: '最大回撤', tipKey: 'max_drawdown', value: fmtPct(px.maxDrawdown, 1), status: metricStatus(Math.abs(px.maxDrawdown), 30, 55, true), note: '目前資料區間內' }, + ], 'section_risk'), + metricSection('回報', '股價不同期間的報酬', [ + { label: '1個月', value: fmtPct(px.ret1m, 1), status: metricStatus(px.ret1m, 0, -10), note: '價格報酬' }, + { label: '3個月', value: fmtPct(px.ret3m, 1), status: metricStatus(px.ret3m, 0, -15), note: '價格報酬' }, + { label: '6個月', value: fmtPct(px.ret6m, 1), status: metricStatus(px.ret6m, 0, -20), note: '價格報酬' }, + { label: '1年', value: fmtPct(px.ret1y, 1), status: metricStatus(px.ret1y, 0, -25), note: '價格報酬' }, + { label: '3年', value: fmtPct(px.ret3y, 1), status: metricStatus(px.ret3y, 0, -35), note: '價格報酬' }, + { label: '5年', value: fmtPct(px.ret5y, 1), status: metricStatus(px.ret5y, 0, -45), note: '價格報酬' }, + ], 'section_return'), + metricSection('效率', '獲利品質與資產使用效率', [ + { label: '毛利率', tipKey: 'gross_margin', value: fmtPct(grossMargin, 1), status: metricStatus(grossMargin, 50, 25), note: '定價權與產品組合' }, + { label: '營業利潤率', tipKey: 'op_margin', value: fmtPct(opMargin, 1), status: metricStatus(opMargin, 25, 8), note: '本業獲利效率' }, + { label: '淨利率', tipKey: 'net_margin', value: fmtPct(netMargin, 1), status: metricStatus(netMargin, 15, 0), note: '最後落袋比例' }, + { label: 'ROA', tipKey: 'roa', value: fmtPct(roa, 1), status: metricStatus(roa, 8, 2), note: '淨利 / 資產' }, + { label: 'ROE', tipKey: 'roe', value: fmtPct(roe, 1), status: metricStatus(roe, 15, 5), note: '淨利 / 股東權益' }, + { label: 'FCF Margin', tipKey: 'fcf_margin', value: fmtPct(calcPct(fcf, curY.revenue), 1), status: metricStatus(calcPct(fcf, curY.revenue), 10, 0), note: '自由現金流 / 營收' }, + ], 'section_efficiency'), + metricSection('預測', '公開摘要與尚待接入的共識資料', [ + d.targetPrice != null + ? { label: '1年目標價', tipKey: 'target_price', value: fmtNum(d.targetPrice, 2), status: metricStatus(((d.targetPrice / d.price) - 1) * 100, 15, 0), note: 'Nasdaq summary' } + : { label: '1年目標價', tipKey: 'target_price', missing: true, note: '需分析師目標價資料源' }, + fair + ? { label: 'DCF 公允價值', tipKey: 'dcf', value: fmtNum(fair.fair, 2), status: metricStatus(fair.upside, 20, 0), note: `區間 ${fmtNum(fair.low, 2)} ~ ${fmtNum(fair.high, 2)}` } + : { label: 'DCF 公允價值', tipKey: 'dcf', missing: true, note: '需要正自由現金流與股數' }, + fair + ? { label: '安全邊際', tipKey: 'margin_of_safety', value: fmtPct(fair.upside, 1), status: metricStatus(fair.upside, 20, 0), note: '公允價值相對現價' } + : { label: '安全邊際', tipKey: 'margin_of_safety', missing: true, note: '需要公允價值與現價' }, + fair + ? { label: '估值假設', tipKey: 'dcf_assumption', value: `${fair.growth.toFixed(1)}% / ${fair.discount.toFixed(1)}%`, status: 'na', note: `5年FCF成長 / 折現率,終值成長 ${fair.terminalGrowth.toFixed(1)}%` } + : { label: '估值假設', tipKey: 'dcf_assumption', missing: true, note: '資料不足無法估算' }, + { label: '預估營收', missing: true, note: '需分析師共識資料源' }, + { label: '預估 EPS', tipKey: 'eps', missing: true, note: '需分析師共識資料源' }, + { label: '預估 EBITDA', missing: true, note: '需共識預測或付費 API' }, + { label: '未來 5 年成長', missing: true, note: '需分析師長期預測' }, + ], 'section_forecast'), + strategySummaryHTML(backtests), + metricSection('穩健度', '把目前可得指標整理成可讀結論', [ + { label: '整體評價', value: d.report?.summary?.verdict || '—', status: d.report?.summary?.verdictColor || 'na', note: '來自財報健檢' }, + { label: '綠燈', value: String(d.report?.summary?.good ?? '—'), status: 'good', note: '通過項目' }, + { label: '黃燈', value: String(d.report?.summary?.warn ?? '—'), status: 'warn', note: '留意項目' }, + { label: '紅燈', value: String(d.report?.summary?.bad ?? '—'), status: 'bad', note: '警訊項目' }, + { label: '淨利成長', tipKey: 'rev_growth', value: fmtPct(netGrowth, 1), status: metricStatus(netGrowth, 10, 0), note: yaQ ? '最近季年增' : '年度年增' }, + { label: '資料來源', value: d.source || '—', status: 'na', note: d.asOf ? '最新期別 ' + d.asOf : '' }, + ], 'section_robust'), + ].join(''); +} +async function renderMetricsPane(force) { + const pane = $('#pane-metrics'); + if (needSymbol(pane)) return; + if (!force && STOCK.rendered.metrics === STOCK.symbol) return; + pane.innerHTML = `
正在整理 ${escapeHtml(STOCK.symbol)} 的指標面板…
`; + try { + const [d, h, quote] = await Promise.all([ + loadFundamentals(STOCK.symbol), + api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=max&interval=1d${force ? '&fresh=1' : ''}`), + api(`/api/quote/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => ({})), + ]); + const backtests = await Promise.all([ + api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=buyhold&range=5y`).then(x => ({ ...x, formula: 'hold = true' })).catch(() => null), + api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=sma&range=5y&short=50&long=200`).then(x => ({ ...x, formula: 'sma(close,50) > sma(close,200)' })).catch(() => null), + api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dip&range=5y&drop=15`).then(x => ({ ...x, formula: 'drawdown <= -15%' })).catch(() => null), + api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dca&range=5y&monthly=1000`).then(x => ({ ...x, formula: 'monthly buy' })).catch(() => null), + ]).then(xs => xs.filter(Boolean)); + STOCK.rendered.metrics = STOCK.symbol; + const pstats = priceStats(h.points || []); + const lastPx = (h.points || []).length ? h.points[h.points.length - 1].close : null; + const enriched = { + ...d, + price: quote.price ?? d.price ?? lastPx, + currency: quote.currency || d.currency || h.currency || '', + name: d.name || quote.name || h.name, + marketCap: quote.marketCap ?? d.marketCap, + sharesOutstanding: quote.sharesOutstanding ?? d.sharesOutstanding, + peTrailing: quote.peTrailing ?? d.peTrailing, + targetPrice: quote.targetPrice ?? d.targetPrice, + dividendYield: quote.dividendYield ?? d.dividendYield, + }; + if (enriched.marketCap == null && enriched.price != null && enriched.sharesOutstanding != null) enriched.marketCap = enriched.price * enriched.sharesOutstanding; + const tech = technicalStats(h.points || [], quote); + pane.innerHTML = ` +
+
${escapeHtml(enriched.name || enriched.symbol)}${escapeHtml(enriched.symbol)} · ${escapeHtml(enriched.currency || '')} · ${enriched.asOf ? '最新期別 ' + escapeHtml(enriched.asOf) : ''}
+ +
+
本面板會優先使用 Yahoo、Nasdaq、SEC 與價格歷史等免費公開來源;近即時報價會短暫快取,歷史資料會留在本機,下次只補新日期。MA、RSI、MACD 與布林通道由本機用日線計算;DCF 公允價值以年度自由現金流折現估算。免費報價可能延遲,交易前仍要對照券商報價。
+
${buildMetrics(enriched, pstats, tech, quote, backtests)}
`; + $('#metricRefresh').addEventListener('click', async () => { + STOCK.rendered.metrics = ''; + delete STOCK.fundamentals[STOCK.symbol]; + renderMetricsPane(true); + }); + bindTermTips(pane); + } catch (e) { + pane.innerHTML = `
無法整理 ${escapeHtml(STOCK.symbol)} 的指標:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; + } +} + // ── 價格走勢 ── const PRICE_RANGES = [['3mo', '3月'], ['6mo', '6月'], ['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['max', '全部']]; async function renderPrice(force) { @@ -592,10 +1434,16 @@ async function renderPrice(force) { pane.innerHTML = `
${PRICE_RANGES.map(r => ``).join('')}
-
載入中…
`; +
+
載入中…
+ +
`; $$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); })); try { - const d = await api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`); + const [d, profile] = await Promise.all([ + api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`), + api(`/api/profile/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => null), + ]); STOCK.rendered.price = STOCK.symbol + ':' + STOCK.priceRange; const pts = d.points.map(p => ({ date: p.date, val: p.close })); const first = pts[0].val, last = pts[pts.length - 1].val; @@ -603,10 +1451,114 @@ async function renderPrice(force) { const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg'; $('#priceHead').innerHTML = `${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 ${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%${d.cached ? ' · 快取' : ''}`; drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) }); + renderCompanyProfile(profile, d, last); + renderCompanyIntel(STOCK.symbol, profile, force); } catch (e) { pane.querySelector('#priceChart').innerHTML = `
無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; } } +function renderCompanyProfile(profile, priceData, last) { + const box = $('#companyProfile'); + if (!box) return; + if (!profile) { + box.innerHTML = '
公司資訊暫時無法取得。
'; + return; + } + const q = profile.quote || {}; + const notif = (profile.notifications || []).flatMap(n => n.eventTypes || []).slice(0, 3); + box.innerHTML = ` +
+
${escapeHtml(profile.name || priceData.name || priceData.symbol)}${escapeHtml(profile.exchange || '')} · ${escapeHtml(profile.marketStatus || '')}
+
${fmtNum(q.price ?? last, 2)}
+
+
+
Bid / Ask${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}
+
Sector${escapeHtml(profile.sector || '—')}
+
Industry${escapeHtml(profile.industry || '—')}
+
Region${escapeHtml(profile.region || '—')}
+
+ ${profile.description ? `

${escapeHtml(profile.description)}

` : ''} +
+ ${profile.website ? `公司網站` : ''} + ${profile.address ? `${escapeHtml(profile.address)}` : ''} +
+ ${notif.length ? `
Upcoming${notif.map(e => `${escapeHtml(e.message || e.eventName || '')}`).join('')}
` : ''} +
公司資訊來自 Nasdaq profile / quote,報價可能延遲。
+ `; + const rb = $('#intelRefresh'); + if (rb) rb.addEventListener('click', () => renderCompanyIntel(profile.symbol || STOCK.symbol, profile, true)); +} +function txSignal(t) { + if (t.signal === 'acquire') return ['取得', 'good']; + if (t.signal === 'dispose') return ['處分', 'bad']; + return ['混合', 'warn']; +} +function renderCompanyIntelSkeleton() { + const pane = $('#pane-price'); + let box = $('#companyIntel'); + if (!box) { + pane.insertAdjacentHTML('beforeend', '
'); + box = $('#companyIntel'); + } + box.innerHTML = '
正在整理產業鏈、管理層、內部人交易與新聞…
'; + return box; +} +async function renderCompanyIntel(symbol, profile, fresh) { + const box = renderCompanyIntelSkeleton(); + try { + const intel = await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`); + const chain = intel.industryChain || {}; + const officers = intel.management?.officers || []; + const insiders = intel.insiders || []; + const news = intel.news || []; + const acquiredCount = insiders.filter(t => t.signal === 'acquire').length; + const disposedCount = insiders.filter(t => t.signal === 'dispose').length; + box.innerHTML = ` +
+

產業上下游

先建立研究地圖,再點出去查證供應鏈細節
+
+
上游${(chain.upstream || []).map(x => `${escapeHtml(x)}`).join('')}
+
${escapeHtml(symbol)}${escapeHtml(profile?.industry || intel.management?.industry || '公司核心業務')}
+
下游${(chain.downstream || []).map(x => `${escapeHtml(x)}`).join('')}
+
+ + ${(chain.peers || []).length ? `
${chain.peers.map(s => ``).join('')}
` : ''} +
+
+

經營管理層

職位與薪酬來自公開資料,可用來看治理結構
+
${officers.length ? officers.map(o => ` +
${escapeHtml(o.name)}${escapeHtml(o.title || '')}${o.totalPay != null ? 'Total pay ' + fmtMoney(o.totalPay) : ''}
`).join('') : '
暫時沒有抓到管理層資料。
'}
+ +
+
+

內部人 Form 4

A/D 代表 SEC 交易取得/處分代碼,需留意獎酬與選擇權情境
+
+
${acquiredCount}近期偏取得
+
${disposedCount}近期偏處分
+
+ +
+
+

新聞

最近與公司或相關代號有關的新聞
+ +
`; + $$('.peer-chips button', box).forEach(btn => btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer))); + } catch (e) { + box.innerHTML = `
無法整理公司研究資訊:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; + } +} // ── 財報健檢 ── function renderFinboxPane() { @@ -621,7 +1573,7 @@ async function runFincheck(sym, fresh) { const out = $('#finResult'); if (!out) return; if (!sym) { out.innerHTML = '
請先輸入股票代號。
'; return; } - out.innerHTML = `
正在${fresh ? '重新抓取' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…
`; + out.innerHTML = `
正在${fresh ? '更新' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…
`; try { const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : '')); STOCK.rendered.finbox = sym; @@ -649,7 +1601,7 @@ function renderFincheck(d) { const staleNote = d.stale ? ' · 即時更新失敗,先顯示先前存的資料' : ''; out.innerHTML = `
${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)}${d.price != null ? ` · 股價 $${fmtNum(d.price, 2)}` : ''} · 資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? ` · 最新季別 ${escapeHtml(d.asOf)}` : ''}
-
${freshNote}${staleNote}
+
${freshNote}${staleNote}
${escapeHtml(sum.verdict || '—')}
${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查
@@ -662,7 +1614,10 @@ function renderFincheck(d) { ${caveats}`; bindWlinks(out); const rb = $('#finRefresh'); - if (rb) rb.addEventListener('click', () => runFincheck(d.symbol, true)); + if (rb) rb.addEventListener('click', () => { + delete STOCK.fundamentals[d.symbol]; + runFincheck(d.symbol, false); + }); } function checkRowHTML(ck) { const links = (ck.links || []).map(l => `${escapeHtml(l.label)}`).join(''); @@ -753,7 +1708,7 @@ function drawMap() {
${STOCK.symbol ? '' : ''}
${layersHTML} -
這是把 Emmy「投資底層邏輯」六層漏斗變成的自我檢查工具,幫你結構化判斷,不構成投資建議。任何一層出局就停手,是漏斗的精神。
`; +
這是把「投資底層邏輯」六層漏斗變成的自我檢查工具,幫你結構化判斷,不構成投資建議。任何一層出局就停手,是漏斗的精神。
`; // 綁定:作答(即時重繪)、原則連結、按鈕 $$('.mq-ans', pane).forEach(box => { @@ -776,7 +1731,7 @@ function saveMapToJournal() { const noteLines = statuses.map(s => `${s.i + 1}.${s.title}:${ST_META[s.st].lab}`).join('|'); // 找原則五十四(三面向判斷交易)當預設依據 let principle = ''; - for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = 'Emmy 投資心法#' + p.id; + for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = p.id; openTradeForm({ symbol: STOCK.symbol, entry_reason: verdict, note: '六層漏斗評估:' + noteLines, principle }); } @@ -1008,7 +1963,7 @@ function ensureTradeModal() { } function mountPrincipleChips(container, hiddenInput, selected) { const items = [{ id: '', label: '不指定' }].concat((KB.principles || []).map(p => ({ - id: 'Emmy 投資心法#' + p.id, label: p.title.replace(/^原則[^:]+:/, '').slice(0, 24), + id: p.id, label: (LearnUI.cleanPrincipleTitle ? LearnUI.cleanPrincipleTitle(p.title) : p.title).slice(0, 28), }))); mountChips(container, items, selected || '', v => { hiddenInput.value = v; }, { sm: true }); } diff --git a/index.html b/index.html index 1d18d16..8edd055 100644 --- a/index.html +++ b/index.html @@ -3,27 +3,29 @@ -Emmy 投資台 — 學習 · 個股工具 · 交易復盤 +MacroScope — 學習 · 個股 · 復盤