update dashboard

This commit is contained in:
王性驊 2026-06-04 00:42:07 +08:00
parent ec9ea36610
commit aa38208fff
16 changed files with 4171 additions and 172 deletions

423
app.css
View File

@ -24,8 +24,9 @@ body:not([data-view="macro"]) #navLinks{display:none}
/* ── 頁面 ── */ /* ── 頁面 ── */
.page{margin:28px 32px 0;animation:fadeInUp .35s ease both} .page{margin:28px 32px 0;animation:fadeInUp .35s ease both}
.page-head{margin-bottom:22px} .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} .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{ .disclaimer{
font-size:.78rem;color:var(--text2); font-size:.78rem;color:var(--text2);
background:rgba(255,149,0,.08);border:1px solid rgba(255,149,0,.18); 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)} .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-layout{display:grid;grid-template-columns:240px 1fr;gap:24px;align-items:start}
.learn-side{ .learn-side{
position:sticky;top:88px;display:flex;flex-direction:column;gap:6px; 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{ .learn-side .side-group{
font-size:.68rem;color:var(--text2);letter-spacing:.08em;margin:14px 4px 6px; 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; cursor:pointer;transition:.15s;display:flex;justify-content:space-between;align-items:center;gap:8px;
border:1px solid transparent;background:transparent;text-decoration:none; 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{ .learn-side a.active,.side-tile.active{
background:var(--surface);border-color:var(--border); background:#202421;color:#fff;border-color:#202421;
box-shadow:var(--shadow);font-weight:600; 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); .learn-side a .count{font-size:.68rem;color:var(--text2);background:rgba(0,0,0,.05);
padding:2px 8px;border-radius:20px} padding:2px 8px;border-radius:20px}
.learn-content{min-width:0} .learn-content{min-width:0}
@media(max-width:780px){ @media(max-width:780px){
.learn-hero{grid-template-columns:1fr}
.learn-stats{justify-content:flex-start}
.learn-layout{grid-template-columns:1fr} .learn-layout{grid-template-columns:1fr}
.learn-side{position:static;flex-direction:row;flex-wrap:wrap} .learn-side{position:static;flex-direction:row;flex-wrap:wrap}
.learn-side .side-group{width:100%} .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{margin-bottom:28px}
.stage-title{font-size:1.1rem;font-weight:700;margin-bottom:6px} .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} .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; padding:18px 20px;cursor:pointer;transition:transform .15s,box-shadow .2s;
display:flex;flex-direction:column;gap:8px;box-shadow:var(--shadow); 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-name{font-size:.98rem;font-weight:700}
.module-card .mod-meta{font-size:.76rem;color:var(--text2);line-height:1.55} .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} .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)} .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} .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{ .chart-wrap{
position:relative;width:100%;background:var(--surface); position:relative;width:100%;background:var(--surface);
border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow); border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow);

1065
app.js

File diff suppressed because it is too large Load Diff

View File

@ -3,27 +3,29 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emmy 投資台 — 學習 · 個股工具 · 交易復盤</title> <title>MacroScope — 學習 · 個股 · 復盤</title>
<link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css"> <link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<style> <style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{ :root{
--bg: #f5f5f7; --bg: #f6f7f4;
--surface: #ffffff; --surface: #ffffff;
--card: #ffffff; --card: #ffffff;
--border: rgba(0,0,0,.08); --border: rgba(32,40,33,.10);
--text: #1d1d1f; --text: #202421;
--text2: #86868b; --text2: #667064;
--green: #34c759; --green: #1f9d66;
--red: #ff3b30; --red: #d84f45;
--yellow: #ff9500; --yellow: #c88a1d;
--blue: #0071e3; --blue: #2367c7;
--purple: #af52de; --purple: #7b57c9;
--orange: #ff9500; --orange: #d4772f;
--radius: 14px; --teal: #0f8f8c;
--shadow: 0 2px 20px rgba(0,0,0,.06); --radius: 12px;
--glass: rgba(255,255,255,.72); --shadow: 0 14px 40px rgba(32,40,33,.08);
--soft-shadow: 0 1px 2px rgba(32,40,33,.06),0 12px 28px rgba(32,40,33,.06);
--glass: rgba(255,255,255,.82);
} }
html{font-size:15px;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","PingFang TC","Noto Sans TC",system-ui,sans-serif;-webkit-font-smoothing:antialiased} html{font-size:15px;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","PingFang TC","Noto Sans TC",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
body{min-height:100vh;padding:0 0 60px} body{min-height:100vh;padding:0 0 60px}
@ -36,11 +38,11 @@ a{color:var(--blue);text-decoration:none}
backdrop-filter:saturate(180%) blur(20px); backdrop-filter:saturate(180%) blur(20px);
-webkit-backdrop-filter:saturate(180%) blur(20px); -webkit-backdrop-filter:saturate(180%) blur(20px);
border-bottom:1px solid var(--border); border-bottom:1px solid var(--border);
padding:14px 32px; padding:12px 32px;
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap; display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
} }
.logo{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:700;letter-spacing:-.02em} .logo{display:flex;align-items:center;gap:10px;font-size:1.02rem;font-weight:760;letter-spacing:0}
.logo-icon{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,var(--blue),var(--purple));display:flex;align-items:center;justify-content:center;font-size:.85rem} .logo-icon{width:30px;height:30px;border-radius:8px;background:#202421;color:#f7f3e8;display:flex;align-items:center;justify-content:center;font-size:.82rem}
.header-right{display:flex;align-items:center;gap:16px} .header-right{display:flex;align-items:center;gap:16px}
.last-updated{font-size:.8rem;color:var(--text2)} .last-updated{font-size:.8rem;color:var(--text2)}
.nav-links{display:flex;gap:6px;flex-wrap:wrap} .nav-links{display:flex;gap:6px;flex-wrap:wrap}
@ -52,37 +54,70 @@ a{color:var(--blue);text-decoration:none}
.nav-links a:hover{background:rgba(77,166,255,.08)} .nav-links a:hover{background:rgba(77,166,255,.08)}
.refresh-btn{ .refresh-btn{
background:var(--surface);border:1px solid var(--border);color:var(--text2); background:var(--surface);border:1px solid var(--border);color:var(--text2);
padding:6px 12px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s; padding:8px 12px;border-radius:9px;font-size:.8rem;cursor:pointer;transition:.15s;
box-shadow:0 1px 2px rgba(32,40,33,.04);
} }
.refresh-btn:hover{border-color:var(--blue);color:var(--blue)} .refresh-btn:hover{border-color:var(--blue);color:var(--blue)}
/* ── 導讀條(如何閱讀)── */ /* ── 導讀條(如何閱讀)── */
.guide{ .guide{
margin:20px 32px 0;background:var(--surface);border:1px solid var(--border); margin:20px 32px 0;background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:14px 20px; border-radius:var(--radius);padding:18px 20px;box-shadow:var(--soft-shadow);
} }
.guide-title{font-size:.85rem;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:8px} .guide-title{font-size:.92rem;font-weight:760;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.guide-title .tag{font-size:.68rem;font-weight:600;color:var(--blue);background:rgba(77,166,255,.12);padding:2px 8px;border-radius:20px} .guide-title .tag{font-size:.68rem;font-weight:760;color:#f7f3e8;background:#202421;padding:3px 8px;border-radius:20px}
.guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px 24px;font-size:.78rem;color:var(--text2);line-height:1.55} .guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px 24px;font-size:.78rem;color:var(--text2);line-height:1.55}
.guide-grid b{color:var(--text);font-weight:600} .guide-grid b{color:var(--text);font-weight:600}
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle} .legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
/* ── Macro Signal Bar ── */ /* ── Macro Signal Bar ── */
.signal-bar{ .macro-hero{
margin:20px 32px 0;background:var(--card);border:1px solid var(--border);border-radius:var(--radius); margin:22px 32px 0;
padding:20px 28px;display:grid;grid-template-columns:1fr 2.5fr 1fr;gap:24px;align-items:center; display:grid;grid-template-columns:minmax(280px,1.05fr) minmax(360px,1.7fr) minmax(240px,.9fr);
gap:18px;align-items:stretch;
} }
.hero-panel,.coverage-panel{
background:var(--surface);border:1px solid var(--border);border-radius:16px;
box-shadow:var(--shadow);padding:22px 24px;position:relative;overflow:hidden;
}
.hero-panel::before{
content:'';position:absolute;inset:0 auto 0 0;width:6px;background:var(--hero-color,var(--blue));
}
.hero-kicker{font-size:.72rem;color:var(--text2);font-weight:760;letter-spacing:.08em;margin-bottom:12px;text-transform:uppercase}
.hero-title{font-size:1.65rem;font-weight:820;line-height:1.18;margin-bottom:10px;letter-spacing:0}
.hero-copy{font-size:.88rem;color:var(--text2);line-height:1.7;max-width:620px}
.hero-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
.action-link{
border:1px solid var(--border);background:#f9faf7;color:var(--text);
border-radius:10px;padding:8px 12px;font-size:.8rem;font-weight:720;cursor:pointer;
}
.action-link:hover{border-color:var(--blue);color:var(--blue)}
.signal-bar{
margin:0;background:var(--card);border:1px solid var(--border);border-radius:16px;
padding:20px 24px;display:grid;grid-template-columns:160px 1fr;gap:18px;align-items:center;
box-shadow:var(--shadow);
}
.signal-bar .signal-regime{grid-column:1/-1;text-align:left;border-top:1px solid var(--border);padding-top:14px;display:flex;align-items:center;justify-content:space-between;gap:12px}
.signal-bar .signal-regime .label{margin:0}
.signal-score{text-align:center;position:relative} .signal-score{text-align:center;position:relative}
.signal-score .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:6px} .signal-score .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:6px}
.signal-score .value{font-size:2.8rem;font-weight:700;line-height:1} .signal-score .value{font-size:3.2rem;font-weight:820;line-height:1}
.signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px} .signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px}
.signal-details{display:grid;grid-template-columns:repeat(5,1fr);gap:12px} .signal-details{display:grid;grid-template-columns:repeat(auto-fit,minmax(92px,1fr));gap:10px}
.signal-pill{padding:10px 14px;border-radius:8px;text-align:center;background:var(--surface);border:1px solid var(--border)} .signal-pill{padding:11px 12px;border-radius:10px;text-align:left;background:#f9faf7;border:1px solid var(--border)}
.signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px} .signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px;line-height:1.35}
.signal-pill .pill-value{font-size:1rem;font-weight:600} .signal-pill .pill-value{font-size:1rem;font-weight:600}
.signal-regime{text-align:center} .signal-regime{text-align:center}
.signal-regime .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:8px} .signal-regime .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:8px}
.regime-badge{display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;font-size:.9rem;letter-spacing:.02em} .regime-badge{display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;font-size:.9rem;letter-spacing:.02em}
.coverage-panel{display:flex;flex-direction:column;gap:14px}
.coverage-title{font-size:.92rem;font-weight:780}
.coverage-list{display:grid;gap:10px}
.coverage-row{display:flex;justify-content:space-between;gap:12px;font-size:.8rem;color:var(--text2);border-top:1px solid var(--border);padding-top:10px}
.coverage-row:first-child{border-top:none;padding-top:0}
.coverage-row b{font-size:1rem;color:var(--text)}
.coverage-note{font-size:.76rem;color:var(--text2);line-height:1.6;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px 12px}
.coverage-good{color:var(--green)}.coverage-warn{color:var(--orange)}
/* ── Section ── */ /* ── Section ── */
.section{margin:28px 32px 0} .section{margin:28px 32px 0}
@ -96,10 +131,11 @@ a{color:var(--blue);text-decoration:none}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px} .card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.card{ .card{
background:var(--card);border:1px solid var(--border);border-radius:var(--radius); background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:18px 20px;transition:border-color .2s,box-shadow .2s;position:relative; padding:18px 20px;transition:border-color .2s,box-shadow .2s,transform .2s;position:relative;
display:flex;flex-direction:column; display:flex;flex-direction:column;
box-shadow:0 1px 2px rgba(32,40,33,.04);
} }
.card:hover{border-color:rgba(77,166,255,.3);box-shadow:0 0 20px rgba(77,166,255,.06)} .card:hover{border-color:rgba(35,103,199,.24);box-shadow:var(--soft-shadow);transform:translateY(-2px)}
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px} .card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px}
.card-labels{display:flex;flex-direction:column;gap:1px;min-width:0} .card-labels{display:flex;flex-direction:column;gap:1px;min-width:0}
.card-label{font-size:.82rem;color:var(--text);font-weight:600;line-height:1.3} .card-label{font-size:.82rem;color:var(--text);font-weight:600;line-height:1.3}
@ -118,6 +154,14 @@ a{color:var(--blue);text-decoration:none}
.sub-tag{font-size:.62rem;color:var(--orange);background:rgba(255,138,77,.12);padding:1px 6px;border-radius:4px;align-self:flex-start;margin-bottom:6px} .sub-tag{font-size:.62rem;color:var(--orange);background:rgba(255,138,77,.12);padding:1px 6px;border-radius:4px;align-self:flex-start;margin-bottom:6px}
.card-value{font-size:1.75rem;font-weight:700;line-height:1.1;margin-bottom:2px} .card-value{font-size:1.75rem;font-weight:700;line-height:1.1;margin-bottom:2px}
.card-change{font-size:.75rem;font-weight:500;margin-bottom:10px;min-height:1em} .card-change{font-size:.75rem;font-weight:500;margin-bottom:10px;min-height:1em}
.card-human{font-size:.74rem;color:var(--text2);line-height:1.45;margin:-2px 0 10px;min-height:2.1em}
.card-context{
font-size:.72rem;line-height:1.45;margin:-2px 0 8px;padding:6px 8px;border-radius:8px;
background:rgba(35,103,199,.06);border:1px solid rgba(35,103,199,.12);color:var(--text2);
}
.card-context.good{background:rgba(31,157,102,.07);border-color:rgba(31,157,102,.18);color:#1a6b45}
.card-context.bad{background:rgba(216,79,69,.07);border-color:rgba(216,79,69,.16);color:#9a3a32}
.card-context.warn{background:rgba(200,138,29,.08);border-color:rgba(200,138,29,.18);color:#8a5a12}
.card-sparkline{height:36px;margin-top:auto;opacity:.9} .card-sparkline{height:36px;margin-top:auto;opacity:.9}
.card-meta{display:flex;justify-content:space-between;gap:8px;font-size:.66rem;color:var(--text2);margin-top:8px} .card-meta{display:flex;justify-content:space-between;gap:8px;font-size:.66rem;color:var(--text2);margin-top:8px}
@ -127,16 +171,18 @@ a{color:var(--blue);text-decoration:none}
/* ── Tooltip多行解釋── */ /* ── Tooltip多行解釋── */
#tooltip{ #tooltip{
position:fixed;z-index:1000;max-width:300px;background:#18222f;border:1px solid var(--border); position:fixed;z-index:1000;max-width:360px;background:#202421;border:1px solid rgba(255,255,255,.12);
border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6; border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6;
color:var(--text2);opacity:0;pointer-events:none;transition:opacity .12s; color:#c7d0c5;opacity:0;pointer-events:none;transition:opacity .12s;
} }
#tooltip.show{opacity:1} #tooltip.show{opacity:1}
#tooltip .tip-title{color:var(--text);font-weight:700;font-size:.82rem;margin-bottom:8px} #tooltip .tip-title{color:#fff;font-weight:700;font-size:.82rem;margin-bottom:8px}
#tooltip .tip-row{margin-bottom:7px} #tooltip .tip-row{margin-bottom:7px}
#tooltip .tip-row:last-child{margin-bottom:0} #tooltip .tip-row:last-child{margin-bottom:0}
#tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px} #tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
#tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px} #tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px}
#tooltip .tip-context{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1)}
#tooltip .tip-link-hint{margin-top:8px;font-size:.68rem;color:#8fa0ff}
#tooltip .tip-break{display:flex;justify-content:space-between;gap:12px;margin-bottom:4px} #tooltip .tip-break{display:flex;justify-content:space-between;gap:12px;margin-bottom:4px}
#tooltip .tip-break .d-pos{color:var(--green)}#tooltip .tip-break .d-neg{color:var(--red)} #tooltip .tip-break .d-pos{color:var(--green)}#tooltip .tip-break .d-neg{color:var(--red)}
@ -218,6 +264,7 @@ a{color:var(--blue);text-decoration:none}
/* ── Responsive ── */ /* ── Responsive ── */
@media(max-width:900px){ @media(max-width:900px){
.macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px}
.signal-bar{grid-template-columns:1fr;gap:16px;text-align:center} .signal-bar{grid-template-columns:1fr;gap:16px;text-align:center}
.signal-details{grid-template-columns:repeat(3,1fr)} .signal-details{grid-template-columns:repeat(3,1fr)}
.card.wide{grid-column:span 1} .card.wide{grid-column:span 1}
@ -229,6 +276,7 @@ a{color:var(--blue);text-decoration:none}
@media(max-width:600px){ @media(max-width:600px){
.card-grid{grid-template-columns:1fr} .card-grid{grid-template-columns:1fr}
.signal-details{grid-template-columns:repeat(2,1fr)} .signal-details{grid-template-columns:repeat(2,1fr)}
.signal-bar .signal-regime{display:block;text-align:center}
} }
@keyframes fadeInUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}} @keyframes fadeInUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@ -241,10 +289,11 @@ a{color:var(--blue);text-decoration:none}
<header class="header"> <header class="header">
<div class="logo"> <div class="logo">
<div class="logo-icon">E</div> <div class="logo-icon">E</div>
Emmy 投資台 MacroScope
</div> </div>
<nav class="view-tabs" id="viewTabs"> <nav class="view-tabs" id="viewTabs">
<a data-view="macro" class="active">總經</a> <a data-view="macro" class="active">總經</a>
<a data-view="calendar">日曆</a>
<a data-view="learn">學習教材</a> <a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a> <a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a> <a data-view="journal">交易復盤</a>
@ -252,7 +301,7 @@ a{color:var(--blue);text-decoration:none}
<div class="header-right"> <div class="header-right">
<nav class="nav-links" id="navLinks"></nav> <nav class="nav-links" id="navLinks"></nav>
<span class="last-updated" id="lastUpdated"></span> <span class="last-updated" id="lastUpdated"></span>
<button class="refresh-btn" id="refreshBtn" title="重新抓取最新資料">↻ 更新</button> <button class="refresh-btn" id="refreshBtn" title="檢查並補上最新資料">↻ 更新</button>
</div> </div>
</header> </header>
@ -263,6 +312,7 @@ a{color:var(--blue);text-decoration:none}
正在抓取真實總經資料… 正在抓取真實總經資料…
</div> </div>
</section> </section>
<section class="view" id="view-calendar" hidden></section>
<section class="view" id="view-learn" hidden></section> <section class="view" id="view-learn" hidden></section>
<section class="view" id="view-stock" hidden></section> <section class="view" id="view-stock" hidden></section>
<section class="view" id="view-journal" hidden></section> <section class="view" id="view-journal" hidden></section>
@ -335,13 +385,15 @@ function sparkle(data,w=180,h=36,color){
function arrow(dir){return dir==='up'?'▲':dir==='down'?'▼':'●';} function arrow(dir){return dir==='up'?'▲':dir==='down'?'▼':'●';}
function cardHTML(c){ function cardHTML(c){
TIPS[c.key]={label:c.label,tip:c.tip,substitute:c.substitute}; TIPS[c.key]={label:c.label,tip:c.tip,substitute:c.substitute,context:c.context};
CARD_META[c.key]={label:c.label,labelEn:c.labelEn,colorKey:c.valueColorKey,value:c.value}; CARD_META[c.key]={label:c.label,labelEn:c.labelEn,colorKey:c.valueColorKey,value:c.value};
const valColor=cssVar(c.valueColorKey); const valColor=cssVar(c.valueColorKey);
const chColor=cssVar(c.changeColorKey); const chColor=cssVar(c.changeColorKey);
const sparkHex=HEX[c.valueColorKey]||HEX.blue; const sparkHex=HEX[c.valueColorKey]||HEX.blue;
const subTag=c.substitute?`<span class="sub-tag" title="免費替代指標">替代:${c.substitute}</span>`:''; const subTag=c.substitute?`<span class="sub-tag" title="免費替代指標">替代:${c.substitute}</span>`:'';
const change=c.change?`<div class="card-change" style="color:${chColor}">${arrow(c.dir)} ${c.change}</div>`:`<div class="card-change"></div>`; const change=c.change?`<div class="card-change" style="color:${chColor}">${arrow(c.dir)} ${c.change}</div>`:`<div class="card-change"></div>`;
const ctxTone=c.context?.tone&&c.context.tone!=='neutral'?` ${c.context.tone}`:'';
const ctxLine=c.context?.summary?`<div class="card-context${ctxTone}">${c.context.summary}</div>`:'';
return ` return `
<div class="card" data-key="${c.key}" role="button" tabindex="0" aria-label="${c.label},點擊看走勢"> <div class="card" data-key="${c.key}" role="button" tabindex="0" aria-label="${c.label},點擊看走勢">
<div class="card-top"> <div class="card-top">
@ -357,8 +409,9 @@ function cardHTML(c){
${subTag} ${subTag}
<div class="card-value" style="color:${valColor}">${c.value}</div> <div class="card-value" style="color:${valColor}">${c.value}</div>
${change} ${change}
${ctxLine}
<div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div> <div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div>
<div class="card-meta"><span>資料日 ${c.asOf||'—'}</span></div> <div class="card-meta"><span>資料日 ${c.asOf||'—'}</span><span>點卡片看歷史走勢</span></div>
</div>`; </div>`;
} }
@ -418,18 +471,69 @@ function yieldCurveHTML(yc){
function guideHTML(){ function guideHTML(){
return ` return `
<div class="guide"> <div class="guide">
<div class="guide-title"><span class="tag">新手必讀</span>如何閱讀這個儀表板</div> <div class="guide-title"><span class="tag">閱讀順序</span>不要從每個數字開始,先問三個問題</div>
<div class="guide-grid"> <div class="guide-grid">
<div><b>顏色</b><span class="legend-dot" style="background:var(--green)"></span>綠=偏好訊號、<span class="legend-dot" style="background:var(--red)"></span>紅=警訊、<span class="legend-dot" style="background:var(--yellow)"></span>黃=中性/持平、<span class="legend-dot" style="background:var(--blue)"></span>藍=純數值(無好壞)。</div> <div><b>1. 風險環境</b>:先看健康分數與景氣狀態,只判斷現在是順風、逆風,還是需要保留現金。</div>
<div><b>箭頭 / 徽章</b>:▲上升、▼下降、●持平,描述「和上一期相比的變化方向」。</div> <div><b>2. 壓力來源</b>:再看哪一組在拖累:利率、通膨、就業、信用或市場情緒。這會決定你該研究哪類資產。</div>
<div><b>反向指標</b>:有些指標「數字越高越糟」,例如<b>失業率、VIX、信用利差、衰退機率</b>——這類下降才是好消息。</div> <div><b>3. 證據細節</b>:最後才點卡片看長期走勢與事件標記。不要只看最新值,轉折比單點更重要。</div>
<div><b>總經健康分數</b>:把下方關鍵指標用透明公式加總成 0100 分,越高代表環境對風險性資產越友善(滑到分數上看計算明細)。</div> <div><b>顏色</b><span class="legend-dot" style="background:var(--green)"></span>綠=偏好訊號、<span class="legend-dot" style="background:var(--red)"></span>紅=警訊、<span class="legend-dot" style="background:var(--yellow)"></span>黃=中性、<span class="legend-dot" style="background:var(--blue)"></span>藍=純數值。</div>
<div><b>每張卡片的「?」</b>:滑過去(手機點一下)看白話解釋:這是什麼、怎麼看、對市場的影響。</div> <div><b>反向指標</b>失業率、VIX、信用利差、衰退機率這類「越高越糟」所以下降才是改善。</div>
<div><b>替代指標</b>:少數付費資料(如 ISM、CCI、LEI以免費的等價指標替代卡片上會標示。</div> <div><b>?</b>:滑鼠移上去或點一下,看「這數字代表什麼、歷史上算高還是低、會影響什麼」。卡片上的藍色小字是近 20 年百分位摘要。</div>
<div><b>資料缺口</b>:付費指標會以免費替代資料呈現,抓不到的項目會列在資料覆蓋狀態,不再混在表格裡讓人誤判。</div>
</div> </div>
</div>`; </div>`;
} }
function macroHeroHTML(data, scoreColor, signals){
const total=(data.groups||[]).reduce((n,g)=>n+(g.cards?g.cards.length:0),0);
const substitute=(data.groups||[]).flatMap(g=>g.cards||[]).filter(c=>c.substitute).length;
const degraded=(data.degraded||[]).length;
const scored=(data.breakdown||[]).length;
const score=Number(data.score);
let title='先看風向,再決定要不要出手';
let copy='總經頁會把資料整理成一個「投資天氣」:不是要你背指標,而是快速知道現在順風在哪、風險在哪。';
if(!Number.isNaN(score)){
if(score>=70){title='環境偏順風,但仍要確認估值與部位';copy='健康分數偏高,代表多數總經訊號對風險資產較友善。接著看信用、通膨是否同步支持,避免只被股價情緒帶走。';}
else if(score>=45){title='環境混合,適合分批與等待確認';copy='訊號並不單邊,這時候最重要的是辨認拖累來源。先縮小觀察清單,等數據方向更一致再加大部位。';}
else {title='環境偏逆風,重點是防守與耐心';copy='健康分數偏低,代表風險資產面臨較多壓力。先保護本金、觀察信用和就業是否止穩,再談積極進場。';}
}
return `
<div class="macro-hero">
<section class="hero-panel" style="--hero-color:${scoreColor}">
<div class="hero-kicker">Market read</div>
<div class="hero-title">${title}</div>
<div class="hero-copy">${copy}</div>
<div class="hero-actions">
<button class="action-link" data-scroll-target="group-rates">看利率壓力</button>
<button class="action-link" data-scroll-target="group-money">看信用壓力</button>
<button class="action-link" data-scroll-target="group-history">對照歷史</button>
</div>
</section>
<section class="signal-bar">
<div class="signal-score">
<div class="label">總經健康分數 <button class="info-btn" data-tip-key="__score" aria-label="分數計算說明" tabindex="0">?</button></div>
<div class="value" id="scoreClick" style="color:${scoreColor}" role="button" tabindex="0" title="點擊看分數走勢">${data.score ?? '--'}</div>
<div class="sublabel">/ 100 · 點擊看走勢</div>
</div>
<div class="signal-details">${signals}</div>
<div class="signal-regime">
<div class="label">目前景氣狀態</div>
<div class="regime-badge" style="background:${hexA(data.regime?data.regime.colorKey:'yellow',.15)};color:${scoreColor}">${data.regime?data.regime.label:'--'}</div>
</div>
</section>
<aside class="coverage-panel">
<div class="coverage-title">資料覆蓋狀態</div>
<div class="coverage-list">
<div class="coverage-row"><span>已接入指標</span><b>${total}</b></div>
<div class="coverage-row"><span>納入健康分數</span><b>${scored}</b></div>
<div class="coverage-row"><span>免費替代指標</span><b>${substitute}</b></div>
<div class="coverage-row"><span>暫時缺資料</span><b class="${degraded?'coverage-warn':'coverage-good'}">${degraded}</b></div>
</div>
<div class="coverage-note">${degraded?`有 ${degraded} 個資料源暫時抓不到,頁面仍會顯示其他真實資料。`:'目前主要資料源可用;付費資料已用免費替代指標補上。'}</div>
</aside>
</div>`;
}
function render(data){ function render(data){
const main=document.getElementById('view-macro'); const main=document.getElementById('view-macro');
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow'); const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
@ -442,21 +546,7 @@ function render(data){
// 健康分數說明breakdown放進 TIPS // 健康分數說明breakdown放進 TIPS
TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]}; TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]};
let html = guideHTML(); let html = macroHeroHTML(data, scoreColor, signals) + guideHTML();
html += `
<div class="signal-bar">
<div class="signal-score">
<div class="label">總經健康分數 <button class="info-btn" data-tip-key="__score" aria-label="分數計算說明" tabindex="0">?</button></div>
<div class="value" id="scoreClick" style="color:${scoreColor}" role="button" tabindex="0" title="點擊看分數走勢">${data.score ?? '--'}</div>
<div class="sublabel">/ 100 · 點擊看走勢</div>
</div>
<div class="signal-details">${signals}</div>
<div class="signal-regime">
<div class="label">目前景氣狀態</div>
<div class="regime-badge" style="background:${hexA(data.regime?data.regime.colorKey:'yellow',.15)};color:${scoreColor}">${data.regime?data.regime.label:'--'}</div>
</div>
</div>`;
// 降級提示 // 降級提示
if(data.degraded&&data.degraded.length){ if(data.degraded&&data.degraded.length){
@ -507,6 +597,10 @@ function render(data){
bindTooltips(); bindTooltips();
bindCardClicks(); bindCardClicks();
bindEpisodes(); bindEpisodes();
document.querySelectorAll('[data-scroll-target]').forEach(btn=>btn.addEventListener('click',()=>{
const el=document.getElementById(btn.dataset.scrollTarget);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
}));
const sc=document.getElementById('scoreClick'); const sc=document.getElementById('scoreClick');
if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});} if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});}
} }
@ -597,11 +691,16 @@ function tipContent(key){
} }
const t=TIPS[key]; if(!t||!t.tip) return ''; const t=TIPS[key]; if(!t||!t.tip) return '';
const tip=t.tip; const tip=t.tip;
return `<div class="tip-title">${t.label}</div> const ctx=tip.context;
<div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div> let html=`<div class="tip-title">${t.label}</div>
<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div> <div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div>`;
<div class="tip-row"><span class="tip-k">影響</span>${tip.impact}</div> if(ctx?.number) html+=`<div class="tip-row"><span class="tip-k">這數字</span>${ctx.number}</div>`;
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>`; if(ctx?.history) html+=`<div class="tip-row"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
if(ctx?.affects||tip.impact) html+=`<div class="tip-row"><span class="tip-k">會影響</span>${ctx?.affects||tip.impact}</div>`;
html+=`<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div>
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>
<div class="tip-link-hint">點卡片可看幾十年走勢,對照 2008、2020 等轉折。</div>`;
return html;
} }
function showTip(btn){ function showTip(btn){
const key=btn.dataset.tipKey; const key=btn.dataset.tipKey;
@ -647,7 +746,14 @@ function openModal(key,range){
now.style.color=meta.colorKey?cssVar(meta.colorKey):'var(--text)'; now.style.color=meta.colorKey?cssVar(meta.colorKey):'var(--text)';
renderRangeBtns(); renderRangeBtns();
const tip=TIPS[key]&&TIPS[key].tip; const tip=TIPS[key]&&TIPS[key].tip;
document.getElementById('modalTip').innerHTML=tip?`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`:''; const ctx=tip&&tip.context;
let modalTip='';
if(ctx){
modalTip=`<div><span class="tip-k">這數字</span>${ctx.number}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">會影響</span>${ctx.affects||tip.impact||''}</div>`;
}else if(tip) modalTip=`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`;
document.getElementById('modalTip').innerHTML=modalTip;
document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:''; document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:'';
overlay.classList.add('show'); overlay.classList.add('show');
loadSeries(); loadSeries();
@ -855,6 +961,8 @@ document.addEventListener('DOMContentLoaded',()=>load(false));
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script> <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<script src="lib/glossary.js"></script>
<script src="lib/learn-html.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

144
lib/calendar-cache.js Normal file
View File

@ -0,0 +1,144 @@
// 日曆資料庫快取:以「日」為單位,預設 24 小時內不重抓;重啟 server 仍保留
import { buildCalendar } from './calendar.js';
import { localizeCalendarPayload } from './calendar-i18n.js';
import { getCachedEntry, putCachedJSON } from './db.js';
export const CALENDAR_DAY_MS = (Number(process.env.CALENDAR_TTL_HOURS) || 24) * 3600 * 1000;
export function calendarCacheDay(d = new Date()) {
return d.toISOString().slice(0, 10);
}
function baseKey(day) {
return `calendar:base:v5:${day}`;
}
function earnKey(day, symbols) {
return `calendar:earn:v5:${day}:${[...symbols].sort().join(',') || '_'}`;
}
function watchlistKey() {
return 'calendar:watchlist:v1';
}
export function getCalendarWatchlist() {
const row = getCachedEntry(watchlistKey());
const val = row?.value;
return Array.isArray(val) ? val : [];
}
export function saveCalendarWatchlist(symbols) {
const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30);
putCachedJSON(watchlistKey(), clean);
return clean;
}
function isFresh(entry, day) {
if (!entry?.value) return false;
if (entry.value.cacheDay && entry.value.cacheDay !== day) return false;
return Date.now() - entry.updatedAt < CALENDAR_DAY_MS;
}
function mergeEvents(baseEvents, earnEvents) {
const seen = new Set();
return [...(baseEvents || []), ...(earnEvents || [])]
.filter(ev => {
const key = `${ev.date}|${ev.time}|${ev.category}|${ev.symbol || ''}|${ev.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
}
function filterRange(events, start, end) {
return (events || []).filter(ev => ev.date >= start && ev.date <= end);
}
function wrapResponse(payload, entry, { cached, stale, fetchError } = {}) {
return {
...payload,
cached: !!cached,
stale: !!stale,
fetchError: fetchError || null,
cachedAt: entry?.updatedAt ? new Date(entry.updatedAt).toISOString() : payload.updatedAt,
cacheDay: payload.cacheDay,
};
}
async function buildAndStore({ start, end, symbols, key, day }) {
const payload = await buildCalendar({ start, end, symbols });
const localized = await localizeCalendarPayload(payload);
localized.cacheDay = day;
localized.cachedAt = new Date().toISOString();
putCachedJSON(key, localized);
return { value: localized, updatedAt: Date.now() };
}
export async function getCalendarPayload({ start, end, symbols, forceFresh = false }) {
const day = calendarCacheDay();
const sym = [...symbols].sort();
let baseEntry = getCachedEntry(baseKey(day));
let earnEntry = sym.length ? getCachedEntry(earnKey(day, sym)) : null;
const needBase = forceFresh || !isFresh(baseEntry, day);
const needEarn = sym.length > 0 && (forceFresh || !isFresh(earnEntry, day));
try {
if (needBase) {
baseEntry = await buildAndStore({ start, end, symbols: [], key: baseKey(day), day });
}
if (needEarn) {
earnEntry = await buildAndStore({ start, end, symbols: sym, key: earnKey(day, sym), day });
}
const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings');
const fullEarn = earnEntry?.value?.events || [];
const earnEvents = sym.length ? fullEarn.filter(e => e.category === 'earnings') : [];
const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end);
const sources = baseEntry.value.sources || [];
const payload = {
updatedAt: new Date().toISOString(),
cacheDay: day,
start,
end,
symbols: sym,
events,
sources,
};
return wrapResponse(payload, baseEntry, { cached: !needBase && !needEarn });
} catch (err) {
const staleBase = baseEntry?.value;
const staleEarn = earnEntry?.value;
if (staleBase) {
const baseEvents = (staleBase.events || []).filter(e => e.category !== 'earnings');
const earnEvents = sym.length ? (staleEarn?.events || []).filter(e => e.category === 'earnings') : [];
const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end);
return wrapResponse({
updatedAt: staleBase.updatedAt,
cacheDay: staleBase.cacheDay || day,
start,
end,
symbols: sym,
events,
sources: staleBase.sources || [],
}, baseEntry, { cached: true, stale: true, fetchError: String(err?.message || err) });
}
throw err;
}
}
export async function warmCalendarCache() {
const today = calendarCacheDay();
const start = today;
const end = addDaysISO(today, 60);
if (isFresh(getCachedEntry(baseKey(today)), today)) return;
await buildAndStore({ start, end, symbols: [], key: baseKey(today), day: today });
}
function addDaysISO(iso, days) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}

69
lib/calendar-fred.js Normal file
View File

@ -0,0 +1,69 @@
// FRED 發布日程:補足 BLS/BEA 未涵蓋的重要美國總經零售、房市、ADP、初領失業金等
const UA = 'finance-dashboard/1.0';
const FRED_RELEASES = [
{ id: 9, title: '零售銷售', impact: 'medium', time: '08:30 美東', note: '全美零售與餐飲銷售,反映內需動能' },
{ id: 13, title: '工業生產 / 產能利用率', impact: 'medium', time: '09:15 美東', note: '工廠產出與產能使用率,景氣先行指標' },
{ id: 91, title: '密西根消費者信心', impact: 'medium', time: '10:00 美東', note: '消費者對景氣與通膨的預期,影響風險偏好' },
{ id: 27, title: '新屋開工 / 營建許可', impact: 'medium', time: '08:30 美東', note: '住宅建築活動,利率敏感指標' },
{ id: 291, title: '成屋銷售', impact: 'medium', time: '10:00 美東', note: '既有住宅成交,觀察房市需求' },
{ id: 95, title: '耐久財 / 工廠接單出貨', impact: 'medium', time: '08:30 美東', note: '企業資本支出與製造需求' },
{ id: 14, title: '消費信貸', impact: 'low', time: '15:00 美東', note: '家庭與信用卡借款,觀察消費支撐' },
{ id: 180, title: '初領失業救濟金', impact: 'medium', time: '08:30 美東', note: '每週勞動市場溫度,突增常引發避險' },
{ id: 194, title: 'ADP 私部門就業', impact: 'medium', time: '08:15 美東', note: '非農公布前的私部門就業參考' },
{ id: 351, title: '費城 Fed 製造業指數', impact: 'medium', time: '08:30 美東', note: '製造業景氣調查PMI 類指標' },
{ id: 352, title: '非製造業景氣調查', impact: 'medium', time: '10:00 美東', note: '服務業活動,占美國經濟比重大' },
{ id: 219, title: '芝加哥 Fed 全國活動指數', impact: 'low', time: '08:30 美東', note: '綜合 85 項經濟指標的月度摘要' },
{ id: 11, title: '就業成本指數 ECI', impact: 'medium', time: '08:30 美東', note: '薪資與福利成本Fed 關注的通膨壓力' },
{ id: 16, title: '生產力與單位成本', impact: 'medium', time: '08:30 美東', note: '企業效率與人工成本,影響獲利與通膨' },
];
function getFredKey() {
const key = process.env.FRED_API_KEY;
if (!key || key === 'your_fred_api_key_here') return null;
return key;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchReleaseDates(releaseId, key) {
const url = `https://api.stlouisfed.org/fred/release/dates?release_id=${releaseId}` +
`&include_release_dates_with_no_data=true&limit=100&sort_order=desc&api_key=${key}&file_type=json`;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (res.status === 429) {
await sleep(800 * (attempt + 1));
continue;
}
if (!res.ok) throw new Error(`FRED release ${releaseId} HTTP ${res.status}`);
const data = await res.json();
return (data.release_dates || []).map((row) => row.date);
}
throw new Error(`FRED release ${releaseId} rate limited`);
}
export async function fetchFredMacroEvents(start, end) {
const key = getFredKey();
if (!key) return [];
const events = [];
for (const rel of FRED_RELEASES) {
try {
const dates = await fetchReleaseDates(rel.id, key);
for (const date of dates) {
if (date < start || date > end) continue;
events.push({
date,
time: rel.time,
title: rel.title,
category: 'macro',
impact: rel.impact,
source: 'FRED releases',
note: rel.note,
});
}
} catch {
// 單一來源失敗不阻擋整體日曆
}
await sleep(300);
}
return events;
}

180
lib/calendar-i18n.js Normal file
View File

@ -0,0 +1,180 @@
// 日曆事件繁體中文化:規則對照優先,剩餘英文再用 MyMemory 免費 API結果快取
import { getCachedJSON, putCachedJSON } from './db.js';
const SOURCE_ZH = {
'Federal Reserve': '美國聯準會',
BLS: '美國勞動統計局',
BEA: '美國經濟分析局',
'Nasdaq earnings': 'Nasdaq 財報',
'Market calendar': '市場日曆',
'FRED releases': 'FRED 發布日程',
'Global markets': '全球市場',
ECB: '歐洲央行',
BOJ: '日本央行',
BOE: '英格蘭銀行',
};
const TITLE_RULES = [
[/Consumer Price Index for All Urban Consumers/i, 'CPI 消費者物價(通膨)'],
[/Consumer Price Index|CPI/i, 'CPI 通膨'],
[/Employment Situation|Nonfarm Payrolls|Nonfarm/i, '非農就業 / 失業率'],
[/Producer Price Index|PPI/i, 'PPI 生產者物價'],
[/Job Openings and Labor Turnover|Job Openings|JOLTS/i, 'JOLTS 職缺'],
[/Import and Export Price/i, '進出口物價'],
[/Real Earnings/i, '實質薪資'],
[/Gross Domestic Product|GDP/i, 'GDP 國內生產毛額'],
[/Personal Income and Outlays|Personal Income|Personal Outlays|PCE/i, 'PCE / 個人收入支出'],
[/International Trade in Goods and Services/i, '國際貿易'],
[/Corporate Profits/i, '企業利潤'],
[/FOMC Meeting Minutes|FOMC minutes/i, 'FOMC 會議紀要'],
[/FOMC.*SEP|SEP.*FOMC|dot plot/i, 'FOMC 利率決議 + 點陣圖'],
[/FOMC|Federal Open Market Committee/i, 'FOMC 利率決議'],
[/Retail Sales/i, '零售銷售'],
[/Industrial Production/i, '工業生產'],
[/Housing Starts|Building Permits/i, '住宅相關數據'],
[/Jackson Hole/i, 'Jackson Hole 全球央行年會'],
[/ECB 利率決議/i, 'ECB 利率決議'],
[/日本央行 利率決議/i, '日本央行 利率決議'],
[/英央行 MPC/i, '英央行 MPC 利率決議'],
[/初領失業|Jobless Claims/i, '初領失業救濟金'],
[/ADP/i, 'ADP 私部門就業'],
[/密西根|Surveys of Consumers/i, '密西根消費者信心'],
[/成屋銷售|Existing Home/i, '成屋銷售'],
[/新屋開工|New Residential Construction/i, '新屋開工 / 營建許可'],
[/耐久財|Manufacturer's Shipments/i, '耐久財 / 工廠接單出貨'],
[/工業生產|Industrial Production/i, '工業生產 / 產能利用率'],
[/零售銷售|Retail and Food Services/i, '零售銷售'],
[/月選擇權結算/i, '月選擇權結算'],
[/四巫日/i, '四巫日(衍生品結算)'],
[/美股休市/i, '美股休市'],
[/Employment Cost Index|ECI/i, '就業成本指數 ECI'],
[/Productivity and Costs/i, '生產力 / 單位成本'],
];
const PHRASE_RULES = [
[/Summary of Economic Projections/i, '經濟預測摘要'],
[/dot plot/i, '點陣圖'],
[/policy statement and interest rate decision/i, '政策聲明與利率決議'],
[/News Release/i, '新聞發布'],
[/U\.S\./gi, '美國'],
[/EPS forecast/i, 'EPS 預估'],
[/fiscal quarter ending/i, '財季截止'],
[/FOMC minutes/i, 'FOMC 會議紀要'],
[/Federal Reserve/i, '美國聯準會'],
[/Interest Rate Decision/i, '利率決議'],
[/All Urban Consumers/i, '所有城市消費者'],
[/seasonally adjusted/i, '季調'],
];
function hasLatin(text) {
return /[A-Za-z]{3,}/.test(String(text || ''));
}
function applyRules(text, rules) {
let out = String(text || '').trim();
if (!out) return '';
for (const [re, rep] of rules) out = out.replace(re, rep);
out = out
.replace(/\bET\b/g, '美東')
.replace(/\bEST\b/g, '美東')
.replace(/\bAM\b/g, '上午')
.replace(/\bPM\b/g, '下午')
.replace(/\s+/g, ' ')
.trim();
return out;
}
function localizeTitle(title) {
const t = String(title || '');
for (const [re, rep] of TITLE_RULES) {
if (re.test(t)) return rep;
}
return applyRules(t.replace(/^U\.S\.\s*/i, '').replace(/\s+News Release.*$/i, ''), TITLE_RULES);
}
function localizeNote(note) {
return applyRules(note, PHRASE_RULES);
}
function localizeTime(time) {
if (!time) return '';
return String(time)
.replace(/\bET\b/g, '美東')
.replace(/\bAM\b/g, '上午')
.replace(/\bPM\b/g, '下午')
.trim();
}
function localizeEarningsNote(note, symbol, name) {
const parts = [];
if (name && name !== symbol) parts.push(name);
const eps = String(note || '').match(/EPS forecast\s+([\d.\-]+)/i);
if (eps) parts.push(`EPS 預估 ${eps[1]}`);
const fq = String(note || '').match(/·\s*([\d/]+)\s*$/);
if (fq) parts.push(`財季 ${fq[1]}`);
return parts.join(' · ') || `${symbol} 財報`;
}
async function translateWithMyMemory(text) {
const src = String(text || '').trim().slice(0, 450);
if (!src || !hasLatin(src)) return src;
const cacheKey = `tr:en-zh-TW:${src}`;
const cached = getCachedJSON(cacheKey, 30 * 86400000);
if (cached) return cached;
try {
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(src)}&langpair=en|zh-TW`;
const res = await fetch(url, { headers: { 'User-Agent': 'finance-dashboard/1.0' } });
if (!res.ok) return src;
const data = await res.json();
let out = data?.responseData?.translatedText || src;
if (out === src || /QUERY LENGTH LIMIT/i.test(out)) return src;
out = out.replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
putCachedJSON(cacheKey, out);
return out;
} catch {
return src;
}
}
async function ensureZh(text, apiBudget) {
let out = applyRules(text, PHRASE_RULES);
if (!hasLatin(out)) return out;
if (apiBudget.remaining <= 0) return out;
apiBudget.remaining--;
return translateWithMyMemory(out);
}
export async function localizeCalendarPayload(payload) {
const apiBudget = { remaining: 24 };
const events = [];
for (const ev of payload.events || []) {
let title = ev.title || '';
let note = ev.note || '';
if (ev.category === 'earnings') {
title = title.includes('財報') ? title : `${ev.symbol || ''} 財報`.trim();
note = localizeEarningsNote(note, ev.symbol, (note.split('·')[0] || '').trim());
} else {
title = localizeTitle(title);
note = localizeNote(note);
if (hasLatin(title)) title = await ensureZh(title, apiBudget);
if (hasLatin(note)) note = await ensureZh(note, apiBudget);
}
events.push({
...ev,
title,
note,
time: localizeTime(ev.time),
source: SOURCE_ZH[ev.source] || ev.source,
});
}
return {
...payload,
events,
sources: (payload.sources || []).map(s => ({
...s,
name: SOURCE_ZH[s.name] || s.name,
ok: s.ok,
error: s.error,
})),
};
}

196
lib/calendar-market.js Normal file
View File

@ -0,0 +1,196 @@
// 市場結構日選擇權結算、休市、Jackson Hole、主要央行利率決議靜態日程每年可更新
function iso(y, m, d) {
return new Date(Date.UTC(y, m, d)).toISOString().slice(0, 10);
}
function inRange(date, start, end) {
return date >= start && date <= end;
}
function addEvent(events, ev) {
if (!ev?.date || !ev?.title) return;
events.push({
time: ev.time || '',
impact: ev.impact || 'low',
category: ev.category || 'macro',
source: ev.source || 'Market calendar',
symbol: null,
url: ev.url || null,
note: ev.note || '',
...ev,
});
}
function thirdFridayISO(year, monthIndex) {
const dow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay();
const day = 1 + ((5 - dow + 7) % 7) + 14;
return iso(year, monthIndex, day);
}
const QUAD_MONTHS = new Set([2, 5, 8, 11]);
export function fetchOptionsExpiryEvents(start, end) {
const events = [];
const y0 = Number(start.slice(0, 4));
const y1 = Number(end.slice(0, 4));
for (let y = y0; y <= y1; y++) {
for (let mo = 0; mo < 12; mo++) {
const date = thirdFridayISO(y, mo);
if (!inRange(date, start, end)) continue;
if (QUAD_MONTHS.has(mo)) continue; // 四巫日由 calendar.js 另列
addEvent(events, {
date,
time: '16:00 美東',
title: '月選擇權結算',
category: 'derivatives',
impact: 'medium',
note: '每月第三個週五,個股與指數選擇權到期,換倉時波動可能加大',
});
}
}
return events;
}
function nthWeekday(year, monthIndex, weekday, n) {
let count = 0;
for (let day = 1; day <= 31; day++) {
const d = new Date(Date.UTC(year, monthIndex, day));
if (d.getUTCMonth() !== monthIndex) break;
if (d.getUTCDay() !== weekday) continue;
count++;
if (count === n) return iso(year, monthIndex, day);
}
return null;
}
function lastWeekday(year, monthIndex, weekday) {
for (let day = 31; day >= 1; day--) {
const d = new Date(Date.UTC(year, monthIndex, day));
if (d.getUTCMonth() !== monthIndex) continue;
if (d.getUTCDay() === weekday) return iso(year, monthIndex, day);
}
return null;
}
function observedFixed(year, monthIndex, day) {
const d = new Date(Date.UTC(year, monthIndex, day));
const dow = d.getUTCDay();
if (dow === 6) return iso(year, monthIndex, day - 1);
if (dow === 0) return iso(year, monthIndex, day + 1);
return iso(year, monthIndex, day);
}
function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = c % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31) - 1;
const day = ((h + l - 7 * m + 114) % 31) + 1;
return iso(year, month, day);
}
function goodFriday(year) {
const e = easterSunday(year);
const d = new Date(e + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 2);
return d.toISOString().slice(0, 10);
}
export function fetchUsMarketHolidayEvents(start, end) {
const events = [];
const y0 = Number(start.slice(0, 4));
const y1 = Number(end.slice(0, 4));
for (let y = y0; y <= y1; y++) {
const holidays = [
[observedFixed(y, 0, 1), '美股休市:元旦', '全美主要交易所休市,流動性偏低'],
[nthWeekday(y, 0, 1, 3), '美股休市:馬丁路德金恩日', ''],
[nthWeekday(y, 1, 1, 3), '美股休市:總統日', ''],
[goodFriday(y), '美股休市Good Friday', '復活節前一週五,歐美常同步休市'],
[lastWeekday(y, 4, 1), '美股休市:陣亡將士紀念日', ''],
[observedFixed(y, 5, 19), '美股休市Juneteenth', ''],
[observedFixed(y, 6, 4), '美股休市:獨立紀念日', ''],
[nthWeekday(y, 8, 1, 1), '美股休市:勞動節', ''],
[nthWeekday(y, 10, 4, 4), '美股休市:感恩節', ''],
[observedFixed(y, 11, 25), '美股休市:聖誕節', ''],
];
for (const [date, title, note] of holidays) {
if (!date || !inRange(date, start, end)) continue;
addEvent(events, {
date,
title,
category: 'market',
impact: 'medium',
note: note || '交易所休市或提早收盤,留意流動性',
});
}
const jackson = lastWeekday(y, 7, 5);
if (jackson && inRange(jackson, start, end)) {
addEvent(events, {
date: jackson,
time: '美東日間',
title: 'Jackson Hole 全球央行年會',
category: 'fed',
impact: 'high',
note: 'Fed 主席與各國央行官員演講,常影響利率與風險資產預期',
});
}
}
return events;
}
// 官方已公布的 2026 利率決議日(超出範圍者會被 start/end 自動濾掉)
const CENTRAL_BANK_MEETINGS = [
{ date: '2026-02-05', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-03-19', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-04-30', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-06-11', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-07-23', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-09-10', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-10-29', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-12-17', title: 'ECB 利率決議', source: 'ECB', impact: 'high', note: '歐洲央行貨幣政策決議與記者會' },
{ date: '2026-01-24', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議,影響日圓與亞股' },
{ date: '2026-03-19', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-05-01', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-06-17', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-07-31', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-09-19', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-10-30', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-12-19', title: '日本央行 利率決議', source: 'BOJ', impact: 'high', note: '日本銀行貨幣政策決議' },
{ date: '2026-02-05', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-03-19', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-05-07', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-06-18', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-08-06', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-09-17', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-11-05', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
{ date: '2026-12-17', title: '英央行 MPC 利率決議', source: 'BOE', impact: 'high', note: '英格蘭銀行貨幣政策委員會決議' },
];
export function fetchCentralBankEvents(start, end) {
const events = [];
for (const row of CENTRAL_BANK_MEETINGS) {
if (!inRange(row.date, start, end)) continue;
addEvent(events, {
date: row.date,
time: '依各國時間',
title: row.title,
category: 'central_bank',
impact: row.impact,
source: row.source,
note: row.note,
});
}
return events;
}
export function fetchMarketStructureEvents(start, end) {
return [
...fetchOptionsExpiryEvents(start, end),
...fetchUsMarketHolidayEvents(start, end),
...fetchCentralBankEvents(start, end),
];
}

359
lib/calendar.js Normal file
View File

@ -0,0 +1,359 @@
// ═══════════════════════════════════════════════════════════
// calendar.js — 重大事件日曆(免費/官方來源優先)
// 來源:
// - Federal Reserve FOMC calendar利率決議、SEP/點陣圖、會議紀要)
// - BLS 官方 iCalendarCPI、就業、PPI、JOLTS 等)
// - BEA release scheduleGDP、PCE、Personal Income/Outlays
// - Nasdaq earnings calendar追蹤股票財報日
// - 四巫日3/6/9/12 月第三個週五,衍生品結算)
// - FRED 發布日程零售、房市、ADP、初領失業金等
// - 市場結構日月選擇權結算、美股休市、Jackson Hole、ECB/BOJ/BOE
// ═══════════════════════════════════════════════════════════
import { fetchFredMacroEvents } from './calendar-fred.js';
import { fetchMarketStructureEvents } from './calendar-market.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
const MONTHS = {
january: 0, jan: 0, february: 1, feb: 1, march: 2, mar: 2, april: 3, apr: 3,
may: 4, june: 5, jun: 5, july: 6, jul: 6, august: 7, aug: 7,
september: 8, sep: 8, october: 9, oct: 9, november: 10, nov: 10, december: 11, dec: 11,
};
const IMPACT_WORDS = [
['FOMC', 'high'], ['Federal Funds', 'high'], ['Interest Rate', 'high'],
['CPI', 'high'], ['Consumer Price Index', 'high'], ['Employment Situation', 'high'],
['Nonfarm', 'high'], ['Payroll', 'high'], ['PCE', 'high'], ['GDP', 'high'],
['Producer Price', 'medium'], ['PPI', 'medium'], ['Job Openings', 'medium'],
['JOLTS', 'medium'], ['Retail Sales', 'medium'], ['Personal Income', 'medium'],
['Import and Export Price', 'medium'], ['Productivity and Costs', 'medium'],
['Employment Cost Index', 'medium'], ['Real Earnings', 'low'],
];
const BLS_SKIP = /Metropolitan Area|State Employment|State Job Openings|County Employment|American Time Use|Quarterly Data Series on Business Employment Dynamics|Usual Weekly Earnings|State Unemployment \(Monthly\)/i;
const TITLE_MAP = [
[/Consumer Price Index|CPI/i, 'CPI 通膨'],
[/Employment Situation|Nonfarm|Payroll/i, '非農就業 / 失業率'],
[/Producer Price|PPI/i, 'PPI 生產者物價'],
[/Job Openings|JOLTS/i, 'JOLTS 職缺'],
[/Import and Export Price/i, '進出口物價'],
[/Real Earnings/i, '實質薪資'],
[/GDP/i, 'GDP'],
[/Personal Income|Personal Outlays|PCE/i, 'PCE / 個人收入支出'],
[/Import and Export Price/i, '進出口物價'],
[/Productivity and Costs/i, '生產力 / 單位成本'],
[/Employment Cost Index/i, '就業成本指數 ECI'],
];
const DEDUP_GROUPS = [
[/cpi|消費者物價/i, 'cpi'],
[/非農|employment situation/i, 'nfp'],
[/ppi|生產者物價/i, 'ppi'],
[/jolts|職缺/i, 'jolts'],
[/gdp|國內生產/i, 'gdp'],
[/pce|個人收入支出/i, 'pce'],
[/fomc.*紀要|會議紀要|minutes/i, 'fomc_min'],
[/fomc|利率決議|sep|點陣/i, 'fomc'],
[/四巫/i, 'quad_witch'],
[/月選擇權結算/i, 'monthly_opex'],
[/就業成本|employment cost index|\beci\b/i, 'eci'],
[/生產力|productivity and costs/i, 'productivity'],
[/國際貿易|international trade/i, 'trade'],
[/新屋開工|營建許可|housing starts|building permits/i, 'housing'],
[/零售銷售|retail sales/i, 'retail'],
[/工業生產|industrial production/i, 'indpro'],
[/密西根|consumer sentiment|surveys of consumers/i, 'umich'],
[/初領失業|jobless claims/i, 'claims'],
[/adp/i, 'adp'],
[/ec\s*利率|ecb/i, 'ecb'],
[/日本央行|boj/i, 'boj'],
[/英央行|mpc/i, 'boe'],
];
const IMPACT_RANK = { high: 0, medium: 1, low: 2 };
async function text(url, headers = {}, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*', ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally { clearTimeout(timer); }
}
async function json(url, headers = {}, ms = 12000) {
return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms));
}
function iso(y, m, d) {
return new Date(Date.UTC(y, m, d)).toISOString().slice(0, 10);
}
function inRange(date, start, end) {
return date >= start && date <= end;
}
function impact(title) {
for (const [word, level] of IMPACT_WORDS) if (title.toLowerCase().includes(word.toLowerCase())) return level;
return 'low';
}
function shortTitle(title) {
for (const [re, label] of TITLE_MAP) if (re.test(title)) return label;
return String(title || '').replace(/^U\.S\.\s*/i, '').replace(/\s+News Release.*$/i, '').trim();
}
function addEvent(events, ev) {
if (!ev?.date || !ev?.title) return;
events.push({
time: ev.time || '',
impact: ev.impact || impact(ev.title),
category: ev.category || 'macro',
source: ev.source || '',
symbol: ev.symbol || null,
url: ev.url || null,
note: ev.note || '',
...ev,
});
}
function parseICalDate(raw) {
const m = String(raw || '').match(/(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2}))?/);
if (!m) return null;
return { date: `${m[1]}-${m[2]}-${m[3]}`, time: m[4] ? `${m[4]}:${m[5]}` : '' };
}
function unfoldICS(src) {
return src.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, '');
}
function parseICS(src) {
const blocks = unfoldICS(src).split('BEGIN:VEVENT').slice(1);
return blocks.map(block => {
const get = (name) => {
const re = new RegExp(`^${name}(?:;[^:]*)?:(.*)$`, 'mi');
return block.match(re)?.[1]?.trim() || '';
};
const dt = parseICalDate(get('DTSTART'));
return dt ? { ...dt, title: get('SUMMARY'), description: get('DESCRIPTION'), url: get('URL') } : null;
}).filter(Boolean);
}
export async function fetchBlsEvents(start, end) {
const events = [];
const ics = await text('https://www.bls.gov/schedule/news_release/bls.ics');
for (const item of parseICS(ics)) {
if (!inRange(item.date, start, end)) continue;
const title = item.title || '';
if (BLS_SKIP.test(title)) continue;
if (impact(title) === 'low' && !/Real Earnings|Import and Export Price/i.test(title)) continue;
addEvent(events, {
date: item.date,
time: item.time || '08:30',
title: shortTitle(title),
category: 'macro',
impact: impact(title),
source: 'BLS',
url: item.url || 'https://www.bls.gov/schedule/news_release/',
note: shortTitle(title),
});
}
return events;
}
export async function fetchBeaEvents(start, end) {
const events = [];
const html = await text('https://www.bea.gov/news/schedule/full');
const rowRe = /<tr[\s\S]*?<\/tr>/gi;
for (const row of html.match(rowRe) || []) {
const clean = row.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const m = clean.match(/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})\s+(\d{1,2}:\d{2}\s+[AP]M)?\s*(.*)/i);
if (!m) continue;
const year = Number((html.match(/<option[^>]*selected[^>]*>\s*(20\d{2})\s*</i) || [])[1]) || new Date().getUTCFullYear();
const date = iso(year, MONTHS[m[1].toLowerCase()], Number(m[2]));
if (!inRange(date, start, end)) continue;
const rawTitle = (m[4] || '').replace(/\bN\s*ews\b/i, '').trim();
if (!/GDP|Personal Income|Personal Outlays|PCE|International Trade|Corporate Profits/i.test(rawTitle)) continue;
addEvent(events, {
date,
time: m[3] || '08:30 AM',
title: shortTitle(rawTitle),
category: 'macro',
impact: impact(rawTitle),
source: 'BEA',
url: 'https://www.bea.gov/news/schedule',
note: shortTitle(rawTitle),
});
}
return events;
}
export async function fetchFomcEvents(start, end) {
const events = [];
const html = await text('https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm');
const yearRe = /<h4[^>]*>\s*<a[^>]*>(20\d{2}) FOMC Meetings<\/a><\/h4>([\s\S]*?)(?=<h4[^>]*>\s*<a[^>]*>20\d{2} FOMC Meetings|$)/gi;
let yMatch;
while ((yMatch = yearRe.exec(html))) {
const year = Number(yMatch[1]);
const section = yMatch[2];
const chunks = section.split(/(?=<div[^>]*fomc-meeting__month)/i).slice(1);
for (const row of chunks) {
const month = row.match(/fomc-meeting__month[\s\S]*?<strong>([^<]+)<\/strong>/i)?.[1]?.trim();
const dateText = row.match(/fomc-meeting__date[^>]*>([^<]+)<\/div>/i)?.[1]?.trim();
if (!month || !dateText) continue;
const monthIndex = MONTHS[month.toLowerCase()];
if (monthIndex == null) continue;
const dayNums = dateText.match(/\d{1,2}/g)?.map(Number) || [];
if (!dayNums.length) continue;
const date = iso(year, monthIndex, dayNums[dayNums.length - 1]);
if (inRange(date, start, end)) {
const hasSep = /\*/.test(dateText) || /Projection|projections|SEP/i.test(row);
addEvent(events, {
date,
time: '14:00 ET',
title: hasSep ? 'FOMC 利率決議 + SEP 點陣圖' : 'FOMC 利率決議',
category: 'fed',
impact: 'high',
source: 'Federal Reserve',
url: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm',
note: hasSep ? '含 Summary of Economic Projections / dot plot' : '政策聲明與利率決議',
});
}
const min = row.match(/Released\s+([A-Za-z]+)\s+(\d{1,2}),\s+(20\d{2})/i);
if (min) {
const minDate = iso(Number(min[3]), MONTHS[min[1].toLowerCase()], Number(min[2]));
if (inRange(minDate, start, end)) addEvent(events, {
date: minDate,
time: '14:00 ET',
title: 'FOMC 會議紀要',
category: 'fed',
impact: 'medium',
source: 'Federal Reserve',
url: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm',
note: `${year}${month} FOMC 會議紀要`,
});
}
}
}
return events;
}
function daysBetween(start, end) {
const out = [];
const d = new Date(start + 'T00:00:00Z');
const e = new Date(end + 'T00:00:00Z');
while (d <= e) {
out.push(d.toISOString().slice(0, 10));
d.setUTCDate(d.getUTCDate() + 1);
}
return out;
}
function nasdaqDate(isoDate) {
const d = new Date(isoDate + 'T00:00:00Z');
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
}
const QUAD_WITCH_MONTHS = [2, 5, 8, 11]; // 3、6、9、12 月
function thirdFridayISO(year, monthIndex) {
const dow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay();
const day = 1 + ((5 - dow + 7) % 7) + 14;
return iso(year, monthIndex, day);
}
export function fetchQuadrupleWitchingEvents(start, end) {
const events = [];
const y0 = Number(start.slice(0, 4));
const y1 = Number(end.slice(0, 4));
for (let y = y0; y <= y1; y++) {
for (const mo of QUAD_WITCH_MONTHS) {
const date = thirdFridayISO(y, mo);
if (!inRange(date, start, end)) continue;
addEvent(events, {
date,
time: '16:00 美東',
title: '四巫日(衍生品結算)',
category: 'derivatives',
impact: 'high',
source: 'Market calendar',
note: '每季第三個週五,期指、指數選擇權、個股選擇權等同日到期,成交與波動常明顯放大',
});
}
}
return events;
}
function dedupeKey(ev) {
const title = String(ev.title || '');
const text = `${title} ${ev.note || ''}`;
for (const [re, slug] of DEDUP_GROUPS) {
if (re.test(title)) return `${ev.date}|${slug}`;
}
for (const [re, slug] of DEDUP_GROUPS) {
if (re.test(text)) return `${ev.date}|${slug}`;
}
return `${ev.date}|${ev.time}|${ev.category}|${ev.symbol || ''}|${ev.title}`;
}
function pickBetterEvent(a, b) {
const ra = IMPACT_RANK[a.impact] ?? 2;
const rb = IMPACT_RANK[b.impact] ?? 2;
if (ra !== rb) return ra < rb ? a : b;
return (a.title?.length || 0) >= (b.title?.length || 0) ? a : b;
}
function dedupeEvents(events) {
const byKey = new Map();
for (const ev of events) {
const key = dedupeKey(ev);
const prev = byKey.get(key);
byKey.set(key, prev ? pickBetterEvent(prev, ev) : ev);
}
return [...byKey.values()];
}
export async function fetchEarningsEvents(start, end, symbols = []) {
const wanted = new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean));
if (!wanted.size) return [];
const events = [];
const headers = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/market-activity/earnings' };
const dates = daysBetween(start, end);
for (let i = 0; i < dates.length; i += 4) {
const chunk = dates.slice(i, i + 4);
const results = await Promise.all(chunk.map(date => json(`https://api.nasdaq.com/api/calendar/earnings?date=${nasdaqDate(date)}`, headers).catch(() => null)));
results.forEach((j, idx) => {
const date = chunk[idx];
const rows = j?.data?.rows || [];
for (const r of rows) {
const sym = String(r.symbol || '').toUpperCase();
if (!wanted.has(sym)) continue;
const name = r.name ? String(r.name).trim() : sym;
const bits = [name !== sym ? name : ''];
if (r.epsForecast) bits.push(`EPS 預估 ${r.epsForecast}`);
if (r.fiscalQuarterEnding) bits.push(`財季 ${r.fiscalQuarterEnding}`);
addEvent(events, {
date,
time: r.time ? String(r.time).replace(/\bET\b/i, '美東').replace(/\bAM\b/i, '上午').replace(/\bPM\b/i, '下午') : '',
title: `${sym} 財報`,
symbol: sym,
category: 'earnings',
impact: 'high',
source: 'Nasdaq earnings',
url: `https://www.nasdaq.com/market-activity/stocks/${sym.toLowerCase()}/earnings`,
note: bits.filter(Boolean).join(' · ') || `${sym} 財報`,
});
}
});
}
return events;
}
export async function buildCalendar({ start, end, symbols }) {
const sourceResults = await Promise.allSettled([
fetchFomcEvents(start, end),
fetchBlsEvents(start, end),
fetchBeaEvents(start, end),
fetchEarningsEvents(start, end, symbols),
Promise.resolve(fetchQuadrupleWitchingEvents(start, end)),
fetchFredMacroEvents(start, end),
Promise.resolve(fetchMarketStructureEvents(start, end)),
]);
const events = dedupeEvents(sourceResults.flatMap(r => r.status === 'fulfilled' ? r.value : []))
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
return {
updatedAt: new Date().toISOString(),
start, end, symbols,
events,
sources: sourceResults.map((r, i) => ({
name: ['Federal Reserve', 'BLS', 'BEA', 'Nasdaq earnings', 'Market calendar', 'FRED releases', 'Global markets'][i],
ok: r.status === 'fulfilled',
error: r.status === 'rejected' ? String(r.reason?.message || r.reason) : null,
})),
};
}

204
lib/companyintel.js Normal file
View File

@ -0,0 +1,204 @@
// ═══════════════════════════════════════════════════════════
// companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈入口
// ═══════════════════════════════════════════════════════════
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
async function text(url, headers = {}, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally { clearTimeout(timer); }
}
async function json(url, headers = {}, ms = 12000) {
return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms));
}
const strip = (s) => String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const num = (s) => {
if (s == null) return null;
const n = Number(String(s).replace(/[$,%\s,]/g, ''));
return Number.isFinite(n) ? n : null;
};
const tag = (src, name) => src.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || null;
let _tickerMap = null;
async function tickerToCik(symbol) {
if (!_tickerMap) {
const d = await json('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA });
_tickerMap = {};
for (const k of Object.keys(d)) _tickerMap[String(d[k].ticker).toUpperCase()] = { cik: String(d[k].cik_str).padStart(10, '0'), name: d[k].title };
}
return _tickerMap[symbol] || null;
}
let _auth = { cookie: null, crumb: null, at: 0 };
async function yahooAuth() {
if (_auth.crumb && Date.now() - _auth.at < 3600e3) return _auth;
const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null);
const cookie = (r1 && (r1.headers.get('set-cookie') || '')).split(';')[0] || '';
const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', { headers: { 'User-Agent': UA, Cookie: cookie } });
const crumb = (await r2.text()).trim();
if (!crumb || crumb.includes('<')) throw new Error('無法取得 Yahoo crumb');
_auth = { cookie, crumb, at: Date.now() };
return _auth;
}
async function fetchManagement(symbol) {
try {
const { cookie, crumb } = await yahooAuth();
const d = await json(`https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=assetProfile&crumb=${encodeURIComponent(crumb)}`, { Cookie: cookie });
const p = d?.quoteSummary?.result?.[0]?.assetProfile || {};
return {
sector: p.sector || null,
industry: p.industry || null,
website: p.website || null,
fullTimeEmployees: p.fullTimeEmployees ?? null,
officers: (p.companyOfficers || []).slice(0, 10).map(o => ({
name: o.name || '',
title: o.title || '',
age: o.age ?? null,
fiscalYear: o.fiscalYear ?? null,
totalPay: o.totalPay?.raw ?? null,
})),
source: 'Yahoo assetProfile',
};
} catch {
return { officers: [], source: null };
}
}
function parseForm4(txt, filing) {
const xml = txt.slice(txt.indexOf('<ownershipDocument'));
const ownerBlock = xml.match(/<reportingOwner>([\s\S]*?)<\/reportingOwner>/i)?.[1] || '';
const issuerBlock = xml.match(/<issuer>([\s\S]*?)<\/issuer>/i)?.[1] || '';
const relBlock = ownerBlock.match(/<reportingOwnerRelationship>([\s\S]*?)<\/reportingOwnerRelationship>/i)?.[1] || '';
const txBlocks = [...xml.matchAll(/<nonDerivativeTransaction>([\s\S]*?)<\/nonDerivativeTransaction>/gi)].map(m => m[1]);
const transactions = txBlocks.slice(0, 8).map(b => ({
date: tag(b, 'transactionDate') ? tag(tag(b, 'transactionDate'), 'value') : null,
code: tag(b, 'transactionCode') || null,
acquiredDisposed: tag(tag(b, 'transactionAcquiredDisposedCode') || '', 'value'),
shares: num(tag(tag(b, 'transactionShares') || '', 'value')),
price: num(tag(tag(b, 'transactionPricePerShare') || '', 'value')),
ownedAfter: num(tag(tag(b, 'sharesOwnedFollowingTransaction') || '', 'value')),
})).filter(t => t.shares != null || t.code);
const acquired = transactions.filter(t => t.acquiredDisposed === 'A').reduce((a, t) => a + (t.shares || 0), 0);
const disposed = transactions.filter(t => t.acquiredDisposed === 'D').reduce((a, t) => a + (t.shares || 0), 0);
return {
filingDate: filing.date,
reportDate: tag(xml, 'periodOfReport'),
owner: tag(ownerBlock, 'rptOwnerName') || strip(txt.match(/COMPANY CONFORMED NAME:\s*([^\n]+)/)?.[1]),
issuer: tag(issuerBlock, 'issuerName'),
title: tag(relBlock, 'officerTitle') || (tag(relBlock, 'isDirector') === '1' ? 'Director' : ''),
isDirector: tag(relBlock, 'isDirector') === '1',
isOfficer: tag(relBlock, 'isOfficer') === '1',
acquired, disposed,
signal: acquired > disposed ? 'acquire' : disposed > acquired ? 'dispose' : 'mixed',
transactions,
url: filing.url,
};
}
async function fetchInsiderTransactions(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
const filings = [];
for (let i = 0; i < (f.form || []).length && filings.length < 8; i++) {
if (f.form[i] !== '4') continue;
const accn = f.accessionNumber[i];
const accNo = accn.replace(/-/g, '');
filings.push({
date: f.filingDate[i],
accn,
url: `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${accn}.txt`,
});
}
const out = [];
for (const filing of filings.slice(0, 5)) {
try { out.push(parseForm4(await text(filing.url, { 'User-Agent': SEC_UA }), filing)); }
catch { /* keep going */ }
}
return out;
}
async function fetchNews(symbol) {
const d = await json(`https://api.nasdaq.com/api/news/topic/articlebysymbol?q=${encodeURIComponent(symbol)}|stocks&offset=0&limit=8&fallback=true`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
}).catch(() => null);
return (d?.data?.rows || []).slice(0, 8).map(r => ({
title: r.title,
publisher: r.publisher,
created: r.created || r.ago,
description: strip(r.description || ''),
url: r.url ? (r.url.startsWith('http') ? r.url : `https://www.nasdaq.com${r.url}`) : null,
relatedSymbols: (r.related_symbols || []).map(x => String(x).split('|')[0].toUpperCase()).filter(Boolean),
}));
}
function industryChain(symbol, profile = {}) {
const industry = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase();
const maps = [
{
match: /semiconductor|chip|accelerated|technology/,
upstream: ['EDA/IP 軟體', '晶圓代工', '先進封裝', 'HBM/記憶體', '半導體設備', 'ABF/載板'],
peers: ['AMD', 'AVGO', 'QCOM', 'MRVL', 'TSM', 'ASML', 'MU'],
downstream: ['雲端資料中心', 'AI 伺服器 OEM/ODM', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'],
},
{
match: /software|internet|communication|media/,
upstream: ['雲端基礎設施', '資料中心', '廣告技術', '內容/資料供應商'],
peers: ['MSFT', 'GOOGL', 'META', 'AMZN', 'CRM', 'ORCL'],
downstream: ['企業客戶', '消費者流量', '開發者生態', '廣告主'],
},
{
match: /consumer|retail|apparel/,
upstream: ['原物料', '製造代工', '物流倉儲', '通路平台'],
peers: ['AMZN', 'WMT', 'COST', 'TGT', 'NKE'],
downstream: ['消費者', '會員訂閱', '門市/電商通路'],
},
];
const hit = maps.find(m => m.match.test(industry)) || {
upstream: ['原物料/零組件', '設備與服務供應商', '物流與通路', '資本支出供應商'],
peers: [],
downstream: ['終端客戶', '企業採購', '通路夥伴', '替代產品'],
};
const q = encodeURIComponent(`${symbol} suppliers customers upstream downstream competitors`);
return {
upstream: hit.upstream,
peers: hit.peers.filter(s => s !== symbol),
downstream: hit.downstream,
searches: [
{ label: '供應商 / 客戶', url: `https://www.google.com/search?q=${q}` },
{ label: '10-K supply chain', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} 10-K suppliers customers supply chain`)}` },
{ label: '同業比較', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} competitors industry peers`)}` },
],
};
}
export async function getCompanyIntel(symbol, profile = {}) {
symbol = String(symbol || '').trim().toUpperCase();
const [management, insiders, news] = await Promise.all([
fetchManagement(symbol),
fetchInsiderTransactions(symbol).catch(() => []),
fetchNews(symbol).catch(() => []),
]);
return {
symbol,
updatedAt: new Date().toISOString(),
management: {
...management,
searches: [
{ label: '管理層 / Leadership', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} executive officers management leadership`)}` },
{ label: 'Proxy / DEF 14A', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} DEF 14A executive compensation board directors`)}` },
{ label: 'Investor relations', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} investor relations leadership`)}` },
],
},
insiders,
news,
industryChain: industryChain(symbol, { ...profile, ...management }),
sources: ['Yahoo assetProfile', 'SEC Form 4', 'Nasdaq News'],
};
}

213
lib/context.js Normal file
View File

@ -0,0 +1,213 @@
// 依真實歷史序列,替總經卡片產生「這數字/歷史上/會影響」白話脈絡
const CONTEXT_YEARS = 20;
function fmtCtx(val, format, decimals = 2) {
const d = decimals ?? 2;
if (!Number.isFinite(val)) return '—';
switch (format) {
case 'pct':
case 'pct_signed': return `${val.toFixed(d)}%`;
case 'bp': return `${Math.round(val)}bp`;
case 'num0': return Math.round(val).toLocaleString('en-US');
case 'num1': return val.toFixed(1);
case 'num2':
case 'num2_signed': return val.toFixed(2);
case 'k':
case 'k_signed': return `${Math.round(val).toLocaleString('en-US')}K`;
case 'trillions': return `$${val.toFixed(d)}T`;
case 'usd': return `$${val.toFixed(d)}`;
case 'usd0': return `$${Math.round(val).toLocaleString('en-US')}`;
default: return val.toFixed(d);
}
}
function percentileRank(values, current) {
if (!values.length || !Number.isFinite(current)) return null;
let below = 0;
for (const v of values) if (v < current) below++;
return Math.round((below / values.length) * 100);
}
function recentWindow(metric, years = CONTEXT_YEARS) {
if (!metric.length) return [];
const cutoff = new Date(metric[metric.length - 1].date);
cutoff.setFullYear(cutoff.getFullYear() - years);
const t = cutoff.getTime();
const window = metric.filter(m => new Date(m.date).getTime() >= t);
return window.length >= 24 ? window : metric;
}
function levelFromPercentile(pct, ind) {
if (pct == null) return { label: '—', tone: 'neutral' };
let label;
if (pct <= 10) label = '極低';
else if (pct <= 30) label = '偏低';
else if (pct <= 70) label = '接近平均';
else if (pct <= 90) label = '偏高';
else label = '極高';
let tone = 'neutral';
if (ind.inverted) {
if (pct >= 70) tone = 'bad';
else if (pct <= 30) tone = 'good';
} else if (!ind.excludeFromScore) {
if (pct >= 70) tone = 'good';
else if (pct <= 30) tone = 'bad';
}
return { label, tone };
}
const NUMBER_EXPLAIN = {
treasury_10y: (v, raw) => `借錢 10 年,市場現在要求年化 ${v} 的利息。等於每借 100 萬,一年約付 ${(raw * 10000).toFixed(0)} 元利息(簡化估算)。`,
treasury_2y: (v) => `借錢 2 年,市場要求年化 ${v} 的利息;通常反映「未來一兩年升息或降息」的預期。`,
fed_funds: (v) => `銀行隔夜互相借錢的「官方上限」約 ${v}。越高=全社會借錢越貴。`,
yield_spread: (v) => `10 年殖利率減 2 年,差距是 ${v}。正值=正常向上斜;負值=倒掛(短借比長借還貴)。`,
real_rate: (v) => `扣掉通膨後,長期「真實」借錢成本約 ${v}`,
cpi: (v) => `一籃子日常東西,平均比一年前貴 ${v}`,
core_cpi: (v) => `扣掉油價與食物後,物價仍比一年前貴 ${v}`,
pce: (v) => `Fed 最在意的通膨指標,比一年前貴 ${v}(目標約 2%)。`,
ppi: (v) => `工廠賣出去的東西,比一年前貴 ${v},常領先 CPI。`,
breakeven: (v) => `債券市場預期,未來 5 年平均通膨約 ${v}`,
unemployment: (v) => `100 個想工作的人裡,約 ${v} 找不到工作。`,
nfp: (v) => `非農業上月新增 ${v} 個工作千人千人200K20 萬人)。`,
claims: (v) => `每週約 ${v} 人第一次申請失業救濟。`,
wages: (v) => `平均時薪比一年前漲 ${v}`,
gdp: (v) => `整體經濟產出比一年前多 ${v}(已扣通膨)。`,
recession_prob: (v) => `模型估計未來 12 個月內,美國陷入衰退的機率約 ${v}`,
credit_spread: (v) => `買高風險債,比公債多要求 ${v} 的額外利息補償。`,
vix: (v) => `市場預期未來 30 天股市會上下震盪的幅度指數;現在約 ${v}20 以下偏平靜30 以上偏恐慌)。`,
m2: (v, raw) => `流通中的廣義貨幣,比一年前 ${raw >= 0 ? '多' : '少'} ${Math.abs(raw).toFixed(1)}%。`,
oil: (v) => `一桶原油 ${v}`,
gold: (v) => `一盎司黃金 ${v}`,
sp500: (v) => `美國 500 大公司的股價加總指數,現在約 ${v} 點。`,
};
const AFFECTS = {
rates: '房貸利率、公司借錢成本、股票估值(利率高→未來利潤折現變便宜→尤其打擊高成長股)、債券價格(利率升→舊債跌價)。',
treasury_10y: '30 年房貸參考利率、企業長期融資、科技/成長股估值、美元走勢。它是「長期借錢有多貴」的標尺。',
treasury_2y: '短期定存、浮動利率貸款、市場對 Fed 升降息的押注。',
yield_spread: '衰退預警、銀行放貸意願、景氣循環(倒掛常領先衰退)。',
inflation: 'Fed 是否升息/降息、薪資購買力、必需消費品價格、債券實質報酬。',
labor: '家庭收入、消費能力、Fed 政策(就業太熱+通膨高→難降息)。',
growth: '企業營收展望、股市盈餘預期、原物料與週期股表現。',
money: '企業融資難易、新興市場資金、信用危機風險、風險資產流動性。',
sentiment: '股市波動、避險情緒、大宗商品與匯率連動。',
credit_spread: '高收益債、小型股、槓桿高的公司——利差一擴大,這些通常先跌。',
vix: '選擇權價格、短線波動、避險資產(黃金、公債)需求。',
recession_prob: '整體股票部位、景氣循環股 vs 防禦股、Fed 降息預期。',
};
function numberExplain(ind, value, formatted) {
const fn = NUMBER_EXPLAIN[ind.key];
if (fn) return fn(formatted, value);
switch (ind.format) {
case 'pct':
case 'pct_signed':
if (ind.transform === 'yoy') return `比一年前變化 ${formatted}`;
return `目前讀數是 ${formatted}`;
case 'bp': return `目前差距是 ${formatted}100bp1%)。`;
case 'k':
case 'k_signed': return `目前讀數約 ${formatted}`;
case 'trillions': return `規模約 ${formatted}`;
case 'usd':
case 'usd0': return `目前價格 ${formatted}`;
default: return `目前讀數是 ${formatted}`;
}
}
function affectsExplain(ind) {
if (AFFECTS[ind.key]) return AFFECTS[ind.key];
if (AFFECTS[ind.group]) return AFFECTS[ind.group];
return ind.tip?.impact || '會連動到其他總經指標與風險資產,建議搭配走勢圖一起看方向。';
}
function benchmarkNote(ind, value) {
if (['cpi', 'core_cpi', 'pce', 'breakeven'].includes(ind.key)) {
const diff = value - 2;
if (Math.abs(diff) <= 0.3) return '接近 Fed 的 2% 通膨目標。';
if (diff > 0.3) return `比 Fed 2% 目標高出約 ${diff.toFixed(1)} 個百分點,偏熱。`;
return `比 Fed 2% 目標低約 ${Math.abs(diff).toFixed(1)} 個百分點,偏冷。`;
}
if (ind.key === 'recession_prob') {
if (value >= 40) return '屬於歷史上偏高的衰退風險區間。';
if (value >= 25) return '已進入值得留意的警戒區(常見閾值 2530%)。';
return '目前模型認為衰退機率不算高。';
}
if (ind.key === 'yield_spread' && value < 0) return '曲線倒掛中——歷史上常領先衰退約 618 個月。';
if (ind.key === 'mfg' || ind.key === 'sentiment_consumer') {
if (value > 0) return '大於 0 代表在擴張/偏樂觀區間。';
return '小於 0 代表在收縮/偏悲觀區間。';
}
return '';
}
function historyExplain(ind, stats, formatted) {
const { pct, min, max, years, level, p50 } = stats;
if (pct == null) return '歷史資料不足,暫無法對照。';
const range = `${fmtCtx(min, ind.format, ind.decimals)} ~ ${fmtCtx(max, ind.format, ind.decimals)}`;
let text = `過去約 ${years} 年落在 ${range};現在 ${formatted},比這段時間裡約 ${pct}% 的時候都${valueWord(ind, pct)}${level.label})。`;
if (p50 != null) {
const med = fmtCtx(p50, ind.format, ind.decimals);
const diff = ind.format === 'bp' ? (stats.current - p50) : (stats.current - p50);
if (Math.abs(diff) > (ind.format === 'bp' ? 15 : 0.15)) {
text += ` 中位數約 ${med},現在${diff > 0 ? '高' : '低'}於中位數。`;
}
}
const bench = benchmarkNote(ind, stats.current);
if (bench) text += ` ${bench}`;
return text;
}
function valueWord(ind, pct) {
if (pct >= 50) return '高';
return '低';
}
function cardSummary(ind, stats) {
if (stats.pct == null) return '';
const y = stats.years;
const p = stats.pct;
const lv = stats.level.label;
if (ind.key === 'treasury_10y') {
return p >= 70 ? `${y}年偏高(第${p}百分位)· 長期借錢偏貴` : p <= 30 ? `${y}年偏低(第${p}百分位)· 長期借錢偏便宜` : `${y}${lv}(第${p}百分位)`;
}
if (ind.inverted) {
return p >= 70 ? `${y}年偏高(第${p}百分位)· 偏警訊` : p <= 30 ? `${y}年偏低(第${p}百分位)· 偏友善` : `${y}${lv}(第${p}百分位)`;
}
if (!ind.excludeFromScore) {
return p >= 70 ? `${y}年偏高(第${p}百分位)· 偏順風` : p <= 30 ? `${y}年偏低(第${p}百分位)· 偏逆風` : `${y}${lv}(第${p}百分位)`;
}
return `${y}${lv}(第${p}百分位)`;
}
export function buildHistoricalContext(metric, ind, value) {
const window = recentWindow(metric);
const vals = window.map(m => m.val).filter(Number.isFinite);
if (!vals.length || !Number.isFinite(value)) return null;
const sorted = [...vals].sort((a, b) => a - b);
const pct = percentileRank(vals, value);
const level = levelFromPercentile(pct, ind);
const p50 = sorted[Math.floor(sorted.length / 2)];
const years = Math.max(1, Math.round((new Date(window[window.length - 1].date) - new Date(window[0].date)) / (365.25 * 86400000)));
const formatted = fmtCtx(value, ind.format, ind.decimals);
const stats = { pct, min: sorted[0], max: sorted[sorted.length - 1], p50, current: value, years, level };
return {
percentile: pct,
level: level.label,
tone: level.tone,
rangeMin: sorted[0],
rangeMax: sorted[sorted.length - 1],
rangeYears: years,
summary: cardSummary(ind, stats),
number: numberExplain(ind, value, formatted),
history: historyExplain(ind, stats, formatted),
affects: affectsExplain(ind),
};
}

View File

@ -9,6 +9,7 @@
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
import { INDICATORS } from './indicators.js'; import { INDICATORS } from './indicators.js';
import { buildHistoricalContext } from './context.js';
const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations'; const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations';
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart/'; const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart/';
@ -264,6 +265,11 @@ async function buildCard(ind) {
const dir = dirOf(delta, scale); const dir = dirOf(delta, scale);
const cls = classify(ind, dir); const cls = classify(ind, dir);
const context = buildHistoricalContext(metric, ind, value);
const tip = context
? { ...ind.tip, context }
: ind.tip;
const card = { const card = {
key: ind.key, key: ind.key,
group: ind.group, group: ind.group,
@ -282,7 +288,8 @@ async function buildCard(ind) {
meaningful: cls.meaningful, meaningful: cls.meaningful,
spark: buildSparkline(metric), spark: buildSparkline(metric),
substitute: ind.substitute || null, substitute: ind.substitute || null,
tip: ind.tip, tip,
context,
format: ind.format, format: ind.format,
decimals: ind.decimals ?? 2, decimals: ind.decimals ?? 2,
asOf: latest.date, asOf: latest.date,

View File

@ -19,6 +19,16 @@ async function jget(url, headers = {}, ms = 9000) {
} }
const num = (x) => (x && typeof x === 'object' && 'raw' in x) ? x.raw : (typeof x === 'number' ? x : null); const num = (x) => (x && typeof x === 'object' && 'raw' in x) ? x.raw : (typeof x === 'number' ? x : null);
const parseNum = (s) => {
if (s == null) return null;
const n = Number(String(s).replace(/[$,%\s,]/g, ''));
return Number.isFinite(n) ? n : null;
};
function parseRange(s) {
if (!s) return {};
const parts = String(s).replace(/\$/g, '').split(/\s*(?:[-]|\/)\s*/).map(parseNum).filter(v => v != null);
return parts.length >= 2 ? { low: Math.min(parts[0], parts[1]), high: Math.max(parts[0], parts[1]) } : {};
}
// 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆) // 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆)
function quarterLabel(endISO) { function quarterLabel(endISO) {
const d = new Date(endISO); if (isNaN(d)) return String(endISO || ''); const d = new Date(endISO); if (isNaN(d)) return String(endISO || '');
@ -34,6 +44,90 @@ function enrich(p) {
// ─── 現價Yahoo chart v8免 crumbquery1 失敗改 query2─── // ─── 現價Yahoo chart v8免 crumbquery1 失敗改 query2───
async function getPrice(symbol) { async function getPrice(symbol) {
let out = {
price: null, name: null, currency: null, exchange: null, marketCap: null, sharesOutstanding: null,
peTrailing: null, targetPrice: null, dividendYield: null, change: null, changePercent: null,
marketTime: null, previousClose: null, dayHigh: null, dayLow: null, volume: null,
avgVolume: null, fiftyTwoWeekHigh: null, fiftyTwoWeekLow: null, fiftyDayAverage: null,
twoHundredDayAverage: null, source: null,
};
try {
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/summary?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
});
const s = n?.data?.summaryData || {};
const dayRange = parseRange(s.TodayHighLow?.value);
const yearRange = parseRange(s.FiftTwoWeekHighLow?.value || s['52WeekHighLow']?.value);
out.marketCap = parseNum(s.MarketCap?.value);
out.targetPrice = parseNum(s.OneYrTarget?.value);
out.dividendYield = parseNum(s.Yield?.value);
out.previousClose = parseNum(s.PreviousClose?.value);
out.price = out.previousClose;
out.dayHigh = dayRange.high ?? out.dayHigh;
out.dayLow = dayRange.low ?? out.dayLow;
out.volume = parseNum(s.ShareVolume?.value);
out.avgVolume = parseNum(s.AverageVolume?.value);
out.fiftyTwoWeekHigh = yearRange.high ?? out.fiftyTwoWeekHigh;
out.fiftyTwoWeekLow = yearRange.low ?? out.fiftyTwoWeekLow;
out.source = 'Nasdaq';
} catch { /* Yahoo fallback below */ }
try {
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/info?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
});
const d = n?.data || {};
const p = d.primaryData || {};
const dayRange = parseRange(d.keyStats?.dayrange?.value);
const yearRange = parseRange(d.keyStats?.fiftyTwoWeekHighLow?.value);
out = {
...out,
price: parseNum(p.lastSalePrice) ?? out.price,
name: d.companyName || out.name,
exchange: d.exchange || out.exchange,
change: parseNum(p.netChange) ?? out.change,
changePercent: parseNum(p.percentageChange) ?? out.changePercent,
marketTime: p.lastTradeTimestamp || out.marketTime,
dayHigh: dayRange.high ?? out.dayHigh,
dayLow: dayRange.low ?? out.dayLow,
volume: parseNum(p.volume) ?? out.volume,
fiftyTwoWeekHigh: yearRange.high ?? out.fiftyTwoWeekHigh,
fiftyTwoWeekLow: yearRange.low ?? out.fiftyTwoWeekLow,
bidPrice: parseNum(p.bidPrice),
askPrice: parseNum(p.askPrice),
marketStatus: d.marketStatus || null,
isRealTime: p.isRealTime ?? null,
notifications: d.notifications || [],
source: out.source ? `${out.source} + Nasdaq Info` : 'Nasdaq Info',
};
} catch { /* Yahoo fallback below */ }
try {
const q = await jget(`https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(symbol)}`);
const r = q?.quoteResponse?.result?.[0];
if (r) out = {
...out,
price: r.regularMarketPrice ?? null,
name: r.shortName || r.longName || null,
currency: r.currency || null,
exchange: r.fullExchangeName || r.exchange || null,
marketCap: r.marketCap ?? out.marketCap,
sharesOutstanding: r.sharesOutstanding ?? null,
peTrailing: r.trailingPE ?? null,
change: r.regularMarketChange ?? null,
changePercent: r.regularMarketChangePercent ?? null,
marketTime: r.regularMarketTime ? new Date(r.regularMarketTime * 1000).toISOString() : null,
previousClose: r.regularMarketPreviousClose ?? out.previousClose,
dayHigh: r.regularMarketDayHigh ?? out.dayHigh,
dayLow: r.regularMarketDayLow ?? out.dayLow,
volume: r.regularMarketVolume ?? out.volume,
avgVolume: r.averageDailyVolume3Month ?? r.averageDailyVolume10Day ?? out.avgVolume,
fiftyTwoWeekHigh: r.fiftyTwoWeekHigh ?? out.fiftyTwoWeekHigh,
fiftyTwoWeekLow: r.fiftyTwoWeekLow ?? out.fiftyTwoWeekLow,
fiftyDayAverage: r.fiftyDayAverage ?? null,
twoHundredDayAverage: r.twoHundredDayAverage ?? null,
source: out.source ? `${out.source} + Yahoo` : 'Yahoo Finance',
};
} catch { /* chart fallback below */ }
if (out.price != null) return out;
for (const host of ['query1', 'query2']) { for (const host of ['query1', 'query2']) {
try { try {
const d = await jget(`https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=5d&interval=1d`); const d = await jget(`https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=5d&interval=1d`);
@ -41,10 +135,53 @@ async function getPrice(symbol) {
if (!r) continue; if (!r) continue;
let p = r.meta?.regularMarketPrice; let p = r.meta?.regularMarketPrice;
if (p == null) { const cl = (r.indicators?.quote?.[0]?.close || []).filter(x => x != null); p = cl.length ? cl[cl.length - 1] : null; } if (p == null) { const cl = (r.indicators?.quote?.[0]?.close || []).filter(x => x != null); p = cl.length ? cl[cl.length - 1] : null; }
return { price: p != null ? p : null, name: r.meta?.shortName || r.meta?.longName || null, currency: r.meta?.currency || null }; return {
...out,
price: p != null ? p : null,
name: out.name || r.meta?.shortName || r.meta?.longName || null,
currency: out.currency || r.meta?.currency || null,
exchange: out.exchange || r.meta?.exchangeName || null,
marketTime: out.marketTime || (r.meta?.regularMarketTime ? new Date(r.meta.regularMarketTime * 1000).toISOString() : null),
source: out.source || 'Yahoo Chart',
};
} catch { /* try next host */ } } catch { /* try next host */ }
} }
return { price: null, name: null, currency: null }; return out;
}
export async function getQuote(symbol) {
return getPrice(String(symbol || '').trim().toUpperCase());
}
export async function getCompanyProfile(symbol) {
symbol = String(symbol || '').trim().toUpperCase();
const [quote, profile] = await Promise.all([
getQuote(symbol).catch(() => ({})),
jget(`https://api.nasdaq.com/api/company/${encodeURIComponent(symbol)}/company-profile?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
}).catch(() => null),
]);
const p = profile?.data || {};
const val = (k) => p[k]?.value ?? null;
return {
symbol,
name: val('CompanyName') || quote.name || symbol,
description: val('CompanyDescription'),
sector: val('Sector'),
industry: val('Industry'),
region: val('Region'),
address: val('Address'),
phone: val('Phone'),
website: val('CompanyUrl'),
exchange: quote.exchange || null,
marketStatus: quote.marketStatus || null,
bidPrice: quote.bidPrice ?? null,
askPrice: quote.askPrice ?? null,
isRealTime: quote.isRealTime ?? null,
notifications: quote.notifications || [],
quote,
source: profile?.data ? 'Nasdaq profile' : quote.source || null,
};
} }
// ─── Yahoo quoteSummary需 cookie + crumb─── // ─── Yahoo quoteSummary需 cookie + crumb───
@ -120,6 +257,9 @@ async function fetchYahoo(symbol) {
const balance = { const balance = {
end: num(bs.endDate) ? new Date(num(bs.endDate) * 1000).toISOString().slice(0, 10) : null, end: num(bs.endDate) ? new Date(num(bs.endDate) * 1000).toISOString().slice(0, 10) : null,
totalAssets, totalLiabilities, totalAssets, totalLiabilities,
totalEquity: num(bs.totalStockholderEquity),
currentAssets: num(bs.totalCurrentAssets),
currentLiabilities: num(bs.totalCurrentLiabilities),
cash: num(bs.cash), cash: num(bs.cash),
totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null, totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null,
debtToAssets: pct(totalLiabilities, totalAssets), debtToAssets: pct(totalLiabilities, totalAssets),
@ -131,6 +271,7 @@ async function fetchYahoo(symbol) {
currency: r.price?.currency || null, currency: r.price?.currency || null,
peTrailing: num(r.summaryDetail?.trailingPE), peTrailing: num(r.summaryDetail?.trailingPE),
marketCap: num(r.price?.marketCap), marketCap: num(r.price?.marketCap),
sharesOutstanding: shares,
quarters, annual, balance, quarters, annual, balance,
}; };
} }
@ -209,18 +350,25 @@ async function fetchEdgar(symbol) {
const assetsE = instantLatest(mergeUnits(g, ['Assets'])); const assetsE = instantLatest(mergeUnits(g, ['Assets']));
const liabE = instantLatest(mergeUnits(g, ['Liabilities'])); const liabE = instantLatest(mergeUnits(g, ['Liabilities']));
const equityE = instantLatest(mergeUnits(g, ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest']));
const curAssetsE = instantLatest(mergeUnits(g, ['AssetsCurrent']));
const curLiabE = instantLatest(mergeUnits(g, ['LiabilitiesCurrent']));
const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents'])); const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents']));
const sharesE = instantLatest(pickShares(g, ['EntityCommonStockSharesOutstanding', 'CommonStocksIncludingAdditionalPaidInCapital']));
const balance = { const balance = {
end: assetsE?.end || null, end: assetsE?.end || null,
totalAssets: assetsE?.val ?? null, totalAssets: assetsE?.val ?? null,
totalLiabilities: liabE?.val ?? null, totalLiabilities: liabE?.val ?? null,
totalEquity: equityE?.val ?? ((assetsE?.val != null && liabE?.val != null) ? assetsE.val - liabE.val : null),
currentAssets: curAssetsE?.val ?? null,
currentLiabilities: curLiabE?.val ?? null,
cash: cashE?.val ?? null, cash: cashE?.val ?? null,
totalDebt: null, totalDebt: null,
debtToAssets: pct(liabE?.val, assetsE?.val), debtToAssets: pct(liabE?.val, assetsE?.val),
}; };
if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)'); if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)');
return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, quarters, annual, balance }; return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, sharesOutstanding: sharesE?.val ?? null, quarters, annual, balance };
} }
// ─── 輕量「是否有新財報」探針(美股;只抓 submissions比 companyfacts 小很多)─── // ─── 輕量「是否有新財報」探針(美股;只抓 submissions比 companyfacts 小很多)───
@ -260,8 +408,11 @@ export async function getFundamentals(symbol) {
source: data.source, source: data.source,
asOf, asOf,
price: priceInfo.price, price: priceInfo.price,
peTrailing: data.peTrailing ?? null, peTrailing: priceInfo.peTrailing ?? data.peTrailing ?? null,
marketCap: data.marketCap ?? null, marketCap: priceInfo.marketCap ?? data.marketCap ?? null,
sharesOutstanding: priceInfo.sharesOutstanding ?? data.sharesOutstanding ?? ((priceInfo.marketCap && priceInfo.price) ? priceInfo.marketCap / priceInfo.price : null),
targetPrice: priceInfo.targetPrice ?? null,
dividendYield: priceInfo.dividendYield ?? null,
quarters: data.quarters || [], quarters: data.quarters || [],
annual: data.annual || [], annual: data.annual || [],
balance: data.balance || {}, balance: data.balance || {},

476
lib/glossary.js Normal file
View File

@ -0,0 +1,476 @@
// 白話名詞解釋 — 供 ? 浮動卡片使用(國高中生也看得懂)
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const TERM_TIPS = {
market_cap: {
label: '市值',
what: '把這家公司所有股票加起來,現在市場願意付多少錢。就像整間店「全部股份」的標價。',
how: '市值大通常代表公司規模大、影響力大,但不代表一定貴或一定好賺。',
},
ev: {
label: '企業價值 EV',
what: '如果要「整間買下來」,大概要花多少。算法是:市值 欠的債 手邊現金。',
how: '比只看市值更公平,因為有些公司欠很多債、有些現金很多,真正價值會不一樣。',
},
pe: {
label: '市盈率 P/E',
what: '你願意用「公司一年淨利」的幾倍價格來買。P/E20 代表:公司一年賺 1 元,你願意花 20 元買。',
how: '數字高=大家期待它未來賺更多;數字低=比較便宜或大家不看好。要搭配成長速度一起看。',
example: '一年淨利 5 元、股價 100 元 → P/E20 倍。',
},
shares: {
label: '流通股數',
what: '可以在市場上自由買賣的股票總張數(或總股數)。',
how: '股數越多,同樣的獲利要分給更多人,每股賺到的錢可能較少。',
},
dividend_yield: {
label: '股息殖利率',
what: '如果公司發現金股息,依現在股價算,一年能領回多少%。',
how: '像定存利率的概念,但股價會漲會跌,股息也可能增減,不是保證收入。',
example: '股價 100、一年股息 3 → 殖利率 3%。',
},
rev_growth: {
label: '營收成長',
what: '公司賣東西/提供服務的收入,跟去年同期比,增加了多少%。',
how: '成長快通常代表生意在擴張;但如果賺不到錢,營收高也不等於好投資。',
},
ma: {
label: '均線 MA',
what: '把最近 N 天的收盤價平均起來畫成一條線。MA20最近 20 天平均僡。',
how: '股價在均線上方,通常代表短中期偏強;在下方則偏弱。常用來看趨勢方向。',
},
rsi: {
label: 'RSI',
what: '最近漲跌力道強不強的指標0100 分。算的是「漲的日子 vs 跌的日子」誰比較猛。',
how: '超過 70 常說「偏熱」、低於 30 常說「偏冷」。只是參考,不是買賣鐵律。',
},
macd: {
label: 'MACD',
what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。',
how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。',
},
boll: {
label: '布林通道',
what: '在均線上下各畫一條「正常波動範圍」的界線,像橡皮筋包著股價。',
how: '貼近上緣可能偏貴、貼近下緣可能偏便宜,但強勢股可以一直沿上緣走。',
},
pos52: {
label: '52 週位置',
what: '現在股價落在過去一年最高價和最低價之間的哪個位置0最低、100最高。',
how: '接近 100 代表在一年高檔,追價要小心;接近 0 可能便宜,但要確認為什麼跌。',
},
volume_ratio: {
label: '成交量 / 均量',
what: '今天成交的股數,是平常平均的幾倍。',
how: '放量(比平常多很多)常代表大家特別關心,可能是好消息或壞消息在發酵。',
},
debt_assets: {
label: '負債 / 總資產',
what: '公司欠的錢,占全部家當的幾%。',
how: '比例越高,景氣不好或利率升時,還債壓力越大。',
},
debt_equity: {
label: '負債股本比',
what: '公司欠多少錢,跟股東投入的錢比,是幾倍。',
how: '大於 1 代表債比股東權益還多,槓桿較高,風險要留意。',
},
current_ratio: {
label: '流動比率',
what: '一年內能變現的資產,夠不夠還一年內要還的債。公式:流動資產 ÷ 流動負債。',
how: '大於 1 代表短期還債能力 OK太低可能有資金週轉問題。',
},
cash_debt: {
label: '現金 / 債務',
what: '手邊現金,跟總負債比,是幾倍。',
how: '大於 1 代表現金就夠還債,財務較有緩衝;太低則景氣差時較危險。',
},
volatility: {
label: '年化波動',
what: '股價每天上上下下的幅度,換算成「一年大概會晃多兇」。',
how: '數字越大,心臟要越大顆;部位要小一點或分批進出。',
},
max_drawdown: {
label: '最大回撤',
what: '從過去某個高點跌到最低點,最多跌了多少%。',
how: '幫你想像「最慘曾經虧多少」。回撤大代表坐雲霄飛車,要評估自己受不受得了。',
},
gross_margin: {
label: '毛利率',
what: '賣 100 元東西,扣掉直接成本後,還剩多少。越高代表產品越「有價錢」。',
how: '科技、品牌公司通常較高;原物料、零售可能較低。',
},
op_margin: {
label: '營業利潤率',
what: '本業賺錢效率:營業利益 ÷ 營收。扣掉管銷等日常開銷後,本業還剩多少%。',
how: '比毛利率更能反映公司「真正會做生意」的程度。',
},
net_margin: {
label: '淨利率',
what: '最後真正落袋的利潤,占營收幾%(扣完稅、利息等所有費用)。',
how: '這才是股東最終能分到的獲利比例。',
},
roa: {
label: 'ROA 資產報酬率',
what: '公司用全部資產(不管誰的錢),一年幫你賺了幾%的淨利。',
how: '看「整盤生意」用資產賺錢的效率,適合比較同產業公司。',
},
roe: {
label: 'ROE 股東權益報酬率',
what: '股東投入的錢,一年幫你賺了幾%的淨利。',
how: '長期 ROE 高且穩,常代表是好公司;但太高可能是借很多債撐出來的,要搭配負債一起看。',
},
fcf_margin: {
label: 'FCF Margin',
what: '自由現金流占營收的%。自由現金流=營運賺到的現金,扣掉維修、擴廠等必要支出後,真正自由運用的錢。',
how: '比「帳上淨利」更難造假,長期為正且成長,通常是健康訊號。',
},
target_price: {
label: '1 年目標價',
what: '券商分析師預測,這檔股票一年後「合理價位」大概在哪。',
how: '只是預測,常偏樂觀;當參考就好,不要當成一定會到的價格。',
},
dcf: {
label: 'DCF 公允價值',
what: '把公司未來可能賺到的現金,一筆一筆折現加總,估算「現在值多少錢」。',
how: '假設(成長率、折現率)一改,結果差很多;適合看區間,不適合當精準股價。',
},
margin_of_safety: {
label: '安全邊際',
what: '估算的合理價值,比現在股價高多少%。代表「便宜緩衝」有多大。',
how: '正值代表現價低於估算值;負值代表可能偏貴。留安全邊際是為了估錯還有退路。',
},
dcf_assumption: {
label: '估值假設',
what: 'DCF 裡你猜的:未來幾年成長多快、折現率(要求報酬)多少、長期成長率多少。',
how: '假設越樂觀,算出來的價值越高;看這行是在提醒「這只是模型,不是真理」。',
},
cagr: {
label: 'CAGR 年化報酬',
what: '如果每年穩穩複利,要多少%才會從起點賺到終點。把整段報酬平均成「每年幾%」。',
how: '回測策略時常用來比「這方法長期平均一年賺多少」。',
},
sma_strategy: {
label: '均線趨勢策略',
what: '短均線在上、長均線在下就持有;反過來就空手。用來跟著大趨勢走。',
how: '震盪盤容易來回被洗;趨勢明顯時較有用。',
},
dip_strategy: {
label: '回落買進',
what: '從近期高點跌達一定幅度(例如 15才買不追在高點。',
how: '可能買在半山腰;適合願意分批、有耐心等回檔的人。',
},
dca_strategy: {
label: '分批投入(定期定額)',
what: '不管高低,固定時間、固定金額買進,不猜最低點。',
how: '降低「一次買在最高點」的風險,但牛市可能平均成本偏高。',
},
fomc: {
label: 'FOMC',
what: '美國聯準會開會決定「要不要調利率、調多少」。全球資金成本都跟著動。',
how: '升息通常打壓估值高的成長股;降息則常有利風險資產。公布當天波動常很大。',
},
dot_plot: {
label: '點陣圖',
what: 'Fed 官員各自預測「未來利率會在哪」的圖,一個點代表一位官員的預期。',
how: '市場會看「中位數」和上次比有沒有變高,來猜還會不會升息。',
},
cpi: {
label: 'CPI 通膨',
what: '一籃子日常用品(食衣住行)跟去年比,平均貴了多少%。就是大家說的「通膨率」。',
how: 'CPI 比預期高 → 市場怕 Fed 繼續升息;比預期低 → 可能降息期待升溫。',
},
nfp: {
label: '非農就業',
what: '美國非農業新增多少工作、失業率多少。看勞動市場健不健康。',
how: '就業太強通膨高Fed 可能不敢降息;就業轉弱則可能刺激降息預期。',
},
pce: {
label: 'PCE',
what: 'Fed 最在意的通膨指標之一,看個人花了多少錢、物價漲多少。比 CPI 範圍更廣一點。',
how: 'Fed 的 2 通膨目標常看 PCE公布時市場反應可能很大。',
},
gdp: {
label: 'GDP',
what: '一個國家一段時間內,所有商品和服務的總價值。看經濟是在成長還是衰退。',
how: 'GDP 成長放緩或負成長,通常代表景氣轉弱,企業獲利可能受壓。',
},
earnings: {
label: '財報',
what: '上市公司每季公布的成績單:賺多少、花多少、展望如何。',
how: '數字或展望不如預期,股價常大跌;超預期則可能大漲。公布前後波動大。',
},
eps: {
label: 'EPS',
what: '每股賺多少錢。把公司淨利除以流通股數,看「每一股」分到多少利潤。',
how: '財報常比「EPS 有沒有 beat 預期」beat比分析師猜的還好。',
},
ppi: {
label: 'PPI',
what: '工廠、批發端賣出去的東西,價格跟去年比漲多少。可當 CPI 的領先指標。',
how: 'PPI 先漲,有時之後 CPI 也會跟著漲。',
},
jolts: {
label: 'JOLTS 職缺',
what: '美國有多少職缺、多少人自願離職。看勞動市場是緊還是鬆。',
how: '職缺多、離職率高,常代表工人議價力強,可能推升薪資和通膨。',
},
quadruple_witching: {
label: '四巫日',
what: '每年 3、6、9、12 月「第三個週五」,期指、指數選擇權、個股選擇權等多種衍生品同一天到期結算。',
how: '到期日前後常見換倉、成交量放大、股價晃動加劇;大型權值股有時波動更明顯。',
},
monthly_opex: {
label: '月選擇權結算',
what: '每個月第三個週五,大量股票與指數選擇權到期,投資人常在此前後調整部位。',
how: '非四巫日的月份仍有結算壓力,但通常比四巫日溫和一些。',
},
adp_employment: {
label: 'ADP 私部門就業',
what: '民間機構統計的「私企新增工作」人數,比官方非農早幾天公布。',
how: '常被當成非農就業的預演;方向一致時市場較安心,差很多則會重新定價。',
},
jobless_claims: {
label: '初領失業救濟金',
what: '每週有多少人第一次申請失業救濟。看裁員是否在升溫。',
how: '每週都有,突然大增代表勞動市場轉弱,可能推升降息預期。',
},
retail_sales: {
label: '零售銷售',
what: '美國商店、餐廳賣了多少東西,看老百姓有沒有在花钱。',
how: '數字強代表內需好;弱則擔心經濟放緩,也可能影響 Fed 政策預期。',
},
industrial_production: {
label: '工業生產',
what: '工廠、礦場、電力等產出量,反映實體經濟是否在擴張。',
how: '下滑常代表製造業轉弱,對原物料、工業股情緒偏空。',
},
michigan_sentiment: {
label: '密西根消費者信心',
what: '問一般家庭對景氣、通膨、購屋的看法,是「信心」指標。',
how: '信心下滑時,市場常擔心消費會縮,對零售、可選消費類股偏空。',
},
housing_data: {
label: '房市數據',
what: '新屋開工、營建許可、成屋銷售等,看住宅市場活不活躍。',
how: '房市對利率很敏感;數據轉弱常代表高利率正在壓抑需求。',
},
durable_goods: {
label: '耐久財訂單',
what: '能用好幾年的大件商品(機器、飛機、電腦等)訂單,看企業投資意願。',
how: '訂單增代表企業願意擴產;大減則暗示景氣與資本支出轉冷。',
},
consumer_credit: {
label: '消費信貸',
what: '信用卡、汽車貸款等借款餘額,看家庭是否靠借錢消費。',
how: '借貸快速增加有時代表需求強,但也可能暗示財務壓力上升。',
},
eci: {
label: '就業成本指數 ECI',
what: '企業付給員工的薪資與福利漲多少Fed 用它看「薪資通膨」。',
how: '薪資漲太快Fed 可能更不敢降息;降溫則有利降息預期。',
},
productivity: {
label: '生產力 / 單位成本',
what: '工人產出效率與每單位產出成本,看通膨是來自薪資還是效率。',
how: '生產力高、成本可控,對企業獲利與通膨都是好消息。',
},
philly_fed: {
label: '費城 Fed 製造業調查',
what: '美國東部製造業經理人的景氣問卷,類似 PMI 的先行指標。',
how: '高於 0 代表擴張、低於 0 代表收縮;公布時短線波動常見。',
},
services_pmi: {
label: '非製造業景氣調查',
what: '服務業(占美國 GDP 大部分)的景氣問卷,看活動是在擴張還是收縮。',
how: '服務業強,通常代表整體經濟仍有支撐;轉弱則偏空。',
},
trade_balance: {
label: '國際貿易',
what: '美國出口、進口與貿易逆差數字,看對外需求與美元流向。',
how: '逆差擴大不一定壞,但會影響 GDP 與匯率討論;公布時市場會快速反應。',
},
import_export_prices: {
label: '進出口物價',
what: '進口與出口商品的價格變化,可當通膨的領先或補充線索。',
how: '進口物價漲,有時預告國內通膨壓力將來會升高。',
},
real_earnings: {
label: '實質薪資',
what: '把通膨扣掉之後,工人薪水實際能買到多少東西。',
how: '實質薪資在漲,消費較有支撐;若通膨吃掉加薪,則消費可能轉弱。',
},
market_holiday: {
label: '美股休市',
what: '紐約證交所休市或提早收盤,當天(或前後)成交量通常較低。',
how: '休市日前後常見部位調整;重要數據若撞上休市,反應可能延後到 reopen。',
},
jackson_hole: {
label: 'Jackson Hole 央行年會',
what: '全球央行官員與經濟學者的大會Fed 主席常發表重要政策談話。',
how: '講話若偏鷹(抗通膨),股市常承壓;偏鴿(願降息)則風險資產較受益。',
},
ecb_rate: {
label: '歐洲央行利率決議',
what: '歐元區的「央行開會決定要不要調利率」,影響歐股、歐元、全球資金。',
how: '升息打壓估值、降息通常有利風險資產;記者會語氣也會被放大解讀。',
},
boj_rate: {
label: '日本央行利率決議',
what: '日本央行的貨幣政策決議,決定是否調整利率或購債。',
how: '日圓、日股、套息交易都會動;若傳出升息或收緊,全球市場常波動。',
},
boe_rate: {
label: '英央行 MPC 決議',
what: '英格蘭銀行貨幣政策委員會決定英國利率,影響英鎊與英股。',
how: '與 Fed、ECB 類似:偏鷹偏空風險資產,偏鴿則相反。',
},
section_market: {
label: '市場總覽',
what: '這一區看「這檔股票現在市場怎麼定價」:股價、大小、貴不貴、成長快不快。',
how: '先建立第一印象,再往下挖財報和技術面。',
},
section_technical: {
label: '技術面',
what: '用股價和成交量的圖表、指標,看短中期趨勢和熱度。',
how: '不能預測公司長期價值,但可幫你決定「現在進場會不會太追」。',
},
section_risk: {
label: '風險',
what: '公司財務健不健康、股價晃多兇。看「最壞情況可能多慘」。',
how: '風險高不代表不能買,但要控制部位、知道自己在賭什麼。',
},
section_return: {
label: '回報',
what: '不同時間長度,買這檔股票賺或虧了多少%。',
how: '看長期15 年)比只看最近一個月更能反映趨勢。',
},
section_efficiency: {
label: '效率',
what: '公司賺錢的「品質」:毛利高不高、用資產和股東的錢賺錢效率高不高。',
how: '好公司通常毛利率、ROE 長期都不錯,且自由現金流跟得上。',
},
section_forecast: {
label: '預測',
what: '分析師目標價、DCF 估算等「向前看」的數字,都是模型和猜測。',
how: '當參考區間,不要當成精準預言。',
},
section_robust: {
label: '穩健度',
what: '把前面各項整理成紅綠燈和簡短結論,方便快速掃描。',
how: '紅燈多就要更保守;綠燈多也要記得沒有百分之百。',
},
};
function termTipBtn(key, label) {
if (!TERM_TIPS[key]) return '';
const aria = label || TERM_TIPS[key].label || key;
return `<button type="button" class="info-btn" data-term-key="${_esc(key)}" aria-label="說明:${_esc(aria)}" tabindex="0">?</button>`;
}
function termTipHTML(t) {
let html = `<div class="tip-title">${_esc(t.label)}</div>`;
html += `<div class="tip-row"><span class="tip-k">白話說</span>${_esc(t.what)}</div>`;
if (t.how) html += `<div class="tip-row"><span class="tip-k">怎麼看</span>${_esc(t.how)}</div>`;
if (t.example) html += `<div class="tip-row"><span class="tip-k">舉例</span>${_esc(t.example)}</div>`;
return html;
}
function termTipContent(key) {
const t = TERM_TIPS[key];
return t ? termTipHTML(t) : '';
}
function showTermTip(btn) {
const el = document.getElementById('tooltip');
if (!el) return;
const html = termTipContent(btn.dataset.termKey);
if (!html) return;
el.innerHTML = html;
el.classList.add('show');
const r = btn.getBoundingClientRect();
const tw = el.offsetWidth, th = el.offsetHeight;
let left = r.left + r.width / 2 - tw / 2;
left = Math.max(10, Math.min(left, window.innerWidth - tw - 10));
let top = r.top - th - 10;
if (top < 10) top = r.bottom + 10;
el.style.left = left + 'px';
el.style.top = top + 'px';
}
function hideTermTip() {
const el = document.getElementById('tooltip');
if (el) el.classList.remove('show');
}
function bindTermTips(root) {
root = root || document;
root.querySelectorAll('.info-btn[data-term-key]').forEach(btn => {
if (btn.dataset.termBound) return;
btn.dataset.termBound = '1';
btn.addEventListener('mouseenter', () => showTermTip(btn));
btn.addEventListener('mouseleave', hideTermTip);
btn.addEventListener('focus', () => showTermTip(btn));
btn.addEventListener('blur', hideTermTip);
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
const el = document.getElementById('tooltip');
el && el.classList.contains('show') ? hideTermTip() : showTermTip(btn);
});
});
}
function glossInline(text) {
const terms = [
['FOMC', 'fomc'], ['點陣圖', 'dot_plot'], ['CPI', 'cpi'], ['非農', 'nfp'],
['PCE', 'pce'], ['GDP', 'gdp'], ['財報', 'earnings'], ['四巫', 'quadruple_witching'],
];
let out = _esc(text);
for (const [word, key] of terms) {
if (!TERM_TIPS[key]) continue;
const re = new RegExp(_esc(word).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
out = out.replace(re, `${_esc(word)}${termTipBtn(key, word)}`);
}
return out;
}
function eventTipKey(title, note) {
const t = `${title || ''} ${note || ''}`;
if (/點陣|SEP|dot plot/i.test(t)) return 'dot_plot';
if (/ADP/i.test(t)) return 'adp_employment';
if (/初領失業|jobless claims/i.test(t)) return 'jobless_claims';
if (/密西根|surveys of consumers/i.test(t)) return 'michigan_sentiment';
if (/零售銷售|retail sales/i.test(t)) return 'retail_sales';
if (/工業生產|industrial production/i.test(t)) return 'industrial_production';
if (/新屋開工|成屋|營建許可|housing starts/i.test(t)) return 'housing_data';
if (/耐久財|manufacturer.*shipments/i.test(t)) return 'durable_goods';
if (/消費信貸|consumer credit/i.test(t)) return 'consumer_credit';
if (/就業成本|\beci\b/i.test(t)) return 'eci';
if (/生產力|productivity and costs/i.test(t)) return 'productivity';
if (/費城 Fed|製造業指數|manufacturing business outlook/i.test(t)) return 'philly_fed';
if (/非製造業|nonmanufacturing business outlook/i.test(t)) return 'services_pmi';
if (/國際貿易|international trade/i.test(t)) return 'trade_balance';
if (/進出口物價|import and export price/i.test(t)) return 'import_export_prices';
if (/實質薪資|real earnings/i.test(t)) return 'real_earnings';
if (/Jackson Hole/i.test(t)) return 'jackson_hole';
if (/歐洲央行|\bECB\b/i.test(t)) return 'ecb_rate';
if (/日本央行|\bBOJ\b/i.test(t)) return 'boj_rate';
if (/英央行|MPC/i.test(t)) return 'boe_rate';
if (/四巫|衍生品結算/i.test(t)) return 'quadruple_witching';
if (/月選擇權結算/i.test(t)) return 'monthly_opex';
if (/美股休市/i.test(t)) return 'market_holiday';
if (/FOMC|聯準會.*利率/i.test(t)) return 'fomc';
if (/CPI|消費者物價/i.test(t)) return 'cpi';
if (/非農|employment situation/i.test(t)) return 'nfp';
if (/PCE|個人收入/i.test(t)) return 'pce';
if (/GDP|國內生產/i.test(t)) return 'gdp';
if (/PPI|生產者物價/i.test(t)) return 'ppi';
if (/JOLTS|職缺/i.test(t)) return 'jolts';
if (/財報/i.test(t)) return 'earnings';
if (/EPS/i.test(t)) return 'eps';
return null;
}

359
lib/learn-html.js Normal file
View File

@ -0,0 +1,359 @@
// 學習教材 HTML 渲染:白話化、互動路徑、案例→通用原則
(function (global) {
const KIND_LABEL = {
overview: '課綱總覽', principleMap: '原則地圖', quiz: '練習題庫',
category: '學習分類', case: '案例講解', principle: '投資原則',
term: '名詞', company: '公司', episode: '單集',
};
const LEARN_PATHS = {
market: {
title: '我想知道:現在大環境適不適合加碼?',
lead: '先別猜明天漲跌。用利率、通膨、就業、信用四條線,決定你該積極還是保守。',
steps: [
{ title: '① 讀懂大環境在說什麼', body: '利率往哪走、通膨高不高、就業強不強——這三個決定「順風還是逆風」。', read: { kind: 'category', id: '總經與利率', label: '總經與利率(分類)' }, practice: { view: 'macro', label: '打開總經儀表板' } },
{ title: '② 用一套流程決定倉位', body: '不是滿倉或空手,而是「這週該偏進攻還是偏防守」。', read: { kind: 'case', id: '總經數據怎麼看', label: '案例:總經數據怎麼看' }, practice: { view: 'macro', label: '對照 CPI、非農卡片' } },
{ title: '③ 把原則記下來', body: '每次調倉都寫「為什麼」,避免事後用結果論合理化。', read: { kind: 'category', id: '交易與資金管理', label: '交易與資金管理' }, practice: { view: 'journal', label: '寫一筆復盤' } },
],
},
stock: {
title: '我想知道:這家公司值不值得研究?',
lead: '把財報、生意模式、估值、趨勢拆成檢查清單,重複用在新公司上。',
steps: [
{ title: '① 財報在說什麼', body: '營收、毛利、EPS、財測——先會讀再談貴不貴。', read: { kind: 'category', id: '財報基本功', label: '財報基本功' }, read2: { kind: 'case', id: 'NVIDIA財報怎麼看', label: '案例:財報怎麼看' }, practice: { view: 'stock', label: '個股工具 · 財報健檢' } },
{ title: '② 生意好不好、有沒有護城河', body: '數字背後是定價權與產業位置,別只看熱門題材。', read: { kind: 'category', id: '護城河與商業模式', label: '護城河與商業模式' }, practice: { view: 'stock', label: '個股工具 · 投資地圖' } },
{ title: '③ 用案例練「可重複的判斷」', body: 'NVDA、台積電只是例子重點是抽出你下次也能用的問題。', read: { kind: 'case', id: 'NVIDIA決策複盤', label: '案例:決策複盤(框架)' }, practice: { view: 'stock', label: '查一檔你關心的股票' } },
],
},
trade: {
title: '我想知道:這筆交易哪裡做對、哪裡做錯?',
lead: '賺賠是結果;真正要檢查的是當初的理由、風險與紀律有沒有成立。',
steps: [
{ title: '① 避開結果論', body: '賺了不代表判斷對,賠了也不代表一定錯——先分清楚。', read: { kind: 'principle', id: '原則九十六結果論陷阱Outcome Bias', label: '原則:結果論陷阱' }, practice: { view: 'journal', label: '打開交易復盤' } },
{ title: '② 進出場要有依據', body: '進場理由、停損/減倉規則寫清楚,復盤才有東西可改。', read: { kind: 'category', id: '交易與資金管理', label: '交易與資金管理' }, practice: { view: 'journal', label: '新增一筆交易' } },
{ title: '③ 定期回頭對照原則', body: '把常犯的錯連回具體原則,下次遇到類似情境才改得動。', read: { kind: 'principleMap', id: '心法地圖', label: '原則地圖(分群索引)' }, practice: { view: 'learn', section: 'quiz', label: '做練習題' } },
],
},
};
const PRINCIPLE_GROUPS = [
{ id: 'macro', label: '大局與倉位', re: /降息|升息|通膨|總經|倉位|現金|信用|利率|PMI|非農|CPI|風險偏好/ },
{ id: 'research', label: '研究與選股', re: /Capex|毛利率|財報|估值|定價權|護城河|供給|營收|EPS|財測|產業/ },
{ id: 'trade', label: '交易與紀律', re: /減倉|停損|結果論|賣|買|趨勢|紀律|倉位|觸發|弱|強|復盤/ },
{ id: 'mind', label: '心態與認知', re: /新聞|情緒|認知|耐心|時間|概率|陷阱|偏誤/ },
];
function deEmmy(text) {
return String(text || '')
.replace(/Emmy 投資心法/g, '投資原則庫')
.replace(/Emmy 投資台/g, '投資學習台')
.replace(/\bEmmy\b/g, '講者')
.replace(/110 條原則/g, '完整原則庫')
.replace(/心法地圖/g, '原則地圖')
.replace(/心法/g, '原則');
}
function cleanPrincipleTitle(title) {
return deEmmy(String(title || ''))
.replace(/^原則[^:]+[:]\s*/, '')
.trim();
}
function extractWikiLinks(body) {
const links = [];
const re = /\[\[([^\]]+)\]\]/g;
let m;
while ((m = re.exec(String(body || '')))) links.push(m[1].trim());
return links;
}
function resolveLinkTarget(raw, linkMap) {
if (!raw || !linkMap) return null;
const key = raw.trim();
return linkMap[key] || linkMap[key.split('#').pop()] || linkMap[key.split('/').pop()] || null;
}
function extractPrinciples(note, linkMap, principles) {
const out = [];
const seen = new Set();
const add = (p) => {
if (!p || seen.has(p.id)) return;
seen.add(p.id);
out.push(p);
};
for (const raw of extractWikiLinks(note.body)) {
const hit = resolveLinkTarget(raw, linkMap);
if (hit && hit.kind === 'principle') {
const p = (principles || []).find(x => x.id === hit.id);
add(p || { id: hit.id, title: raw.split('#').pop() || raw });
}
if (/投資心法#|投資原則#|原則/.test(raw)) {
const id = raw.includes('#') ? raw.split('#').slice(1).join('#') : raw;
const p = (principles || []).find(x => x.id === id || x.title === id);
add(p || { id, title: id });
}
}
return out.slice(0, 12);
}
function leadFromNote(note) {
if (note.summary) return deEmmy(note.summary);
const body = deEmmy(note.body || '');
for (const line of body.split('\n')) {
let l = line.trim();
if (!l || /^#/.test(l)) continue;
if (l.startsWith('>')) l = l.replace(/^>\s?/, '');
l = l.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1').replace(/[*`]/g, '').trim();
if (l.length > 12) return l.slice(0, 160);
}
return '';
}
function buildTocFromMarkdown(md) {
const items = [];
for (const line of String(md || '').split('\n')) {
const m = line.match(/^(#{2,3})\s+(.+)$/);
if (!m) continue;
const level = m[1].length;
const text = deEmmy(m[2].replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a)).trim();
const id = 'sec-' + items.length;
items.push({ level, text, id });
}
return items.slice(0, 14);
}
function renderPathSteps(pathId, path) {
return path.steps.map((step, i) => {
const links = [
step.read ? `<button type="button" class="la-link" data-note-kind="${step.read.kind}" data-note-id="${step.read.id}">${step.read.label}</button>` : '',
step.read2 ? `<button type="button" class="la-link" data-note-kind="${step.read2.kind}" data-note-id="${step.read2.id}">${step.read2.label}</button>` : '',
].filter(Boolean).join('');
const practice = step.practice
? `<button type="button" class="la-practice" data-view="${step.practice.view || ''}" data-section="${step.practice.section || ''}">${step.practice.label}</button>`
: '';
return `<details class="path-step" ${i === 0 ? 'open' : ''}>
<summary><span class="path-n">${i + 1}</span><strong>${deEmmy(step.title)}</strong></summary>
<p>${deEmmy(step.body)}</p>
<div class="path-actions">${links}${practice}</div>
</details>`;
}).join('');
}
function renderHome(opts) {
const esc = opts.escapeHtml;
const cards = [
{ id: 'market', icon: '🌐', ...LEARN_PATHS.market },
{ id: 'stock', icon: '📊', ...LEARN_PATHS.stock },
{ id: 'trade', icon: '📝', ...LEARN_PATHS.trade },
];
return `
<div class="learning-board">
<div class="board-copy">
<div class="eyebrow">從問題開始</div>
<h2>選一個你現在真的想回答的問題</h2>
<p>不用從頭讀完展開下面步驟依序讀一篇 連到工具 做一次判斷同一套問題可以反覆用在不同股票與不同月份</p>
</div>
<div class="learning-cards">
${cards.map(c => `<button type="button" class="learning-card" data-path="${c.id}">
<span class="lc-step">${esc(c.icon)}</span>
<strong>${esc(c.title)}</strong>
<span>${esc(c.lead)}</span>
<em>展開學習步驟</em>
</button>`).join('')}
</div>
</div>
<div id="learnPathHost" class="learn-path-host" hidden>
<div class="learn-path-head">
<button type="button" class="back-link" id="learnPathBack"> 換一個問題</button>
<h2 id="learnPathTitle"></h2>
<p id="learnPathLead"></p>
</div>
<div id="learnPathSteps" class="learn-path-steps"></div>
</div>
<div class="practice-strip">
<div><b></b><span></span></div>
<div><b></b><span></span></div>
<div><b></b><span></span></div>
</div>
<div class="module-grid learn-shortcuts">
<div class="module-card" data-section-jump="principleMap"><div class="mod-name">原則地圖</div><div class="mod-meta">110+ </div></div>
<div class="module-card" data-section-jump="cases"><div class="mod-name">案例講解</div><div class="mod-meta">/events </div></div>
<div class="module-card" data-section-jump="quiz"><div class="mod-name">練習題庫</div><div class="mod-meta"></div></div>
</div>`;
}
function bindHome(container, handlers) {
const host = container.querySelector('#learnPathHost');
const stepsEl = container.querySelector('#learnPathSteps');
const titleEl = container.querySelector('#learnPathTitle');
const leadEl = container.querySelector('#learnPathLead');
const showPath = (id) => {
const path = LEARN_PATHS[id];
if (!path) return;
container.querySelector('.learning-board').hidden = true;
container.querySelector('.practice-strip').hidden = true;
container.querySelector('.learn-shortcuts').hidden = true;
host.hidden = false;
titleEl.textContent = path.title;
leadEl.textContent = path.lead;
stepsEl.innerHTML = renderPathSteps(id, path);
bindPathSteps(stepsEl, handlers);
host.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
container.querySelector('#learnPathBack')?.addEventListener('click', () => {
host.hidden = true;
container.querySelector('.learning-board').hidden = false;
container.querySelector('.practice-strip').hidden = false;
container.querySelector('.learn-shortcuts').hidden = false;
});
container.querySelectorAll('.learning-card[data-path]').forEach(btn => {
btn.addEventListener('click', () => showPath(btn.dataset.path));
});
container.querySelectorAll('[data-section-jump]').forEach(el => {
el.addEventListener('click', () => handlers.showSection(el.dataset.sectionJump));
});
}
function bindPathSteps(root, handlers) {
root.querySelectorAll('.la-link[data-note-kind]').forEach(btn => {
btn.addEventListener('click', () => handlers.openNote(btn.dataset.noteKind, btn.dataset.noteId));
});
root.querySelectorAll('.la-practice[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const v = btn.dataset.view;
if (v === 'learn' && btn.dataset.section) handlers.showSection(btn.dataset.section);
else if (v && v !== 'learn') handlers.goView(v);
});
});
}
function renderArticle(note, opts) {
const esc = opts.escapeHtml;
const kind = note.kind || '';
const kindLabel = KIND_LABEL[kind] || '筆記';
const title = deEmmy(note.title || note.id || '');
const lead = leadFromNote(note);
const toc = buildTocFromMarkdown(note.body);
const principles = (kind === 'case') ? extractPrinciples(note, opts.linkMap, opts.principles) : [];
const fm = note.frontmatter || {};
let tags = '';
if (fm.ticker) tags += `<span class="fm-tag">代號 ${esc([].concat(fm.ticker).join(' / '))}</span>`;
if (fm.sector) tags += `<span class="fm-tag">${esc(fm.sector)}</span>`;
if (fm.category) tags += `<span class="fm-tag">${esc(fm.category)}</span>`;
const principlePanel = principles.length ? `
<section class="la-principles">
<div class="la-principles-head">
<h2>可抽出的通用原則</h2>
<p>這些原則不只適用這一檔公司下次遇到類似情境可以直接拿來問自己</p>
</div>
<div class="la-principle-chips">
${principles.map(p => `<button type="button" class="principle-chip" data-note-kind="principle" data-note-id="${esc(p.id)}">${esc(cleanPrincipleTitle(p.title))}</button>`).join('')}
</div>
</section>` : '';
const tocHtml = toc.length > 2 ? `
<nav class="la-toc" aria-label="本篇目錄">
<div class="la-toc-title">本篇目錄</div>
<div class="la-toc-links">${toc.map(t => `<a href="#${esc(t.id)}" class="lv${t.level}">${esc(t.text)}</a>`).join('')}</div>
</nav>` : '';
let bodyHtml = opts.renderMarkdown(deEmmy(note.body || ''));
toc.forEach((t, i) => {
const re = new RegExp(`(<h${t.level}>)([^<]*${t.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').slice(0, 12)}[^<]*)`, 'i');
if (i === 0) bodyHtml = bodyHtml.replace(re, `$1 id="${t.id}" $2`);
});
const toolActions = [
{ view: 'macro', label: '總經儀表板', sub: '對照利率、通膨' },
{ view: 'stock', label: '個股工具', sub: '查財報、投資地圖' },
{ view: 'journal', label: '交易復盤', sub: '記錄判斷與檢討' },
];
return `
<div class="note-toolbar">
<span class="back-link" id="noteBack"> 返回</span>
${note.kind && note.id ? '<button type="button" class="btn ghost sm" id="noteGraphBtn">🔗 周邊圖譜</button>' : ''}
</div>
${tags ? `<div class="note-frontmatter">${tags}</div>` : ''}
<article class="learn-article">
<header class="la-head">
<div class="la-kind">${esc(kindLabel)}</div>
<h1 class="la-title">${esc(title)}</h1>
${lead ? `<p class="la-lead">${esc(lead)}</p>` : ''}
</header>
${principlePanel}
${tocHtml}
<div class="learn-body md">${bodyHtml}</div>
<footer class="la-footer">
<div class="la-footer-title">把這篇用出去</div>
<div class="la-tool-grid">
${toolActions.map(a => `<button type="button" class="la-tool-card" data-view="${a.view}">
<strong>${a.label}</strong><span>${a.sub}</span>
</button>`).join('')}
</div>
</footer>
</article>`;
}
function bindArticle(container, handlers) {
container.querySelector('#noteBack')?.addEventListener('click', handlers.onBack);
container.querySelector('#noteGraphBtn')?.addEventListener('click', handlers.onGraph);
container.querySelectorAll('.principle-chip[data-note-id], .la-link[data-note-id]').forEach(btn => {
btn.addEventListener('click', () => handlers.openNote(btn.dataset.noteKind, btn.dataset.noteId));
});
container.querySelectorAll('.la-tool-card[data-view]').forEach(btn => {
btn.addEventListener('click', () => handlers.goView(btn.dataset.view));
});
container.querySelectorAll('.la-toc a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const el = container.querySelector(a.getAttribute('href'));
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
function groupPrinciples(principles) {
const groups = PRINCIPLE_GROUPS.map(g => ({ ...g, items: [] }));
const other = { id: 'other', label: '其他', items: [] };
for (const p of principles || []) {
const title = cleanPrincipleTitle(p.title);
const hit = groups.find(g => g.re.test(title));
(hit || other).items.push({ ...p, cleanTitle: title });
}
const out = groups.filter(g => g.items.length);
if (other.items.length) out.push(other);
return out;
}
function renderPrincipleGroups(principles, esc) {
const groups = groupPrinciples(principles);
return groups.map(g => `
<details class="principle-group" open>
<summary>${esc(g.label)} <span class="pg-count">${g.items.length}</span></summary>
<div class="module-grid pg-grid">
${g.items.map(p => `<div class="module-card pg-card" data-id="${esc(p.id)}">
<div class="mod-name">${esc(p.cleanTitle)}</div>
</div>`).join('')}
</div>
</details>`).join('');
}
function renderCaseCards(items, esc, opts) {
opts = opts || {};
return (items || []).map(it => {
const principles = extractPrinciples(it, opts.linkMap, opts.principles);
const badge = principles.length ? `<span class="case-badge">${principles.length} 條可重用原則</span>` : '';
return `<div class="module-card case-card" data-id="${esc(it.id)}">
<div class="mod-name">${esc(deEmmy(it.title))}${badge}</div>
${it.summary ? `<div class="mod-meta">${esc(deEmmy(it.summary))}</div>` : ''}
</div>`;
}).join('');
}
global.LearnUI = {
deEmmy, cleanPrincipleTitle, renderHome, bindHome, renderArticle, bindArticle,
renderPrincipleGroups, renderCaseCards, extractPrinciples, LEARN_PATHS,
};
})(window);

View File

@ -40,8 +40,8 @@ function cutoffDate(range) {
} }
// Nasdaq 免金鑰每日歷史後備Yahoo 429 時用,美股最完整)。 // Nasdaq 免金鑰每日歷史後備Yahoo 429 時用,美股最完整)。
async function fetchNasdaq(symbol, range) { async function fetchNasdaq(symbol, range, fromISO) {
const from = cutoffDate(range), to = new Date().toISOString().slice(0, 10); const from = fromISO || cutoffDate(range), to = new Date().toISOString().slice(0, 10);
const H = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/' }; const H = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/' };
for (const assetclass of ['stocks', 'etf']) { for (const assetclass of ['stocks', 'etf']) {
let j; let j;
@ -59,40 +59,72 @@ async function fetchNasdaq(symbol, range) {
return null; return null;
} }
function normalizeYahooChart(d, symbol, range, interval) {
const r = d?.chart?.result?.[0];
if (!r || !Array.isArray(r.timestamp)) throw new Error('Yahoo 無歷史資料');
const ts = r.timestamp;
const close = r.indicators?.quote?.[0]?.close || [];
const adj = r.indicators?.adjclose?.[0]?.adjclose || [];
const points = [];
for (let i = 0; i < ts.length; i++) {
const c = close[i];
if (c == null) continue; // 跳過缺值(停牌/未成交)
const a = (adj[i] != null) ? adj[i] : c;
points.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), close: c, adjclose: a });
}
if (points.length < 1) throw new Error('歷史資料點過少');
return {
symbol: r.meta?.symbol || symbol,
name: r.meta?.shortName || r.meta?.longName || null,
currency: r.meta?.currency || null,
range, interval, source: 'Yahoo Finance',
points,
};
}
async function fetchYahooHistory(symbol, range, interval, fromISO) {
let lastErr = null;
for (const host of ['query1', 'query2']) {
try {
let url = `https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`;
if (fromISO) {
const period1 = Math.floor(new Date(fromISO + 'T00:00:00Z').getTime() / 1000);
const period2 = Math.floor(Date.now() / 1000);
url += `?period1=${period1}&period2=${period2}&interval=${interval}&includeAdjustedClose=true`;
} else {
url += `?range=${range}&interval=${interval}&includeAdjustedClose=true`;
}
return normalizeYahooChart(await jget(url), symbol, range, interval);
} catch (e) { lastErr = e; }
}
throw lastErr || new Error('無法取得 Yahoo 歷史股價');
}
// 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] } // 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] }
export async function getHistory(symbol, range = '5y', interval = '1d') { export async function getHistory(symbol, range = '5y', interval = '1d') {
if (!RANGES.includes(range)) range = '5y'; if (!RANGES.includes(range)) range = '5y';
if (!INTERVALS.includes(interval)) interval = '1d'; if (!INTERVALS.includes(interval)) interval = '1d';
let lastErr = null; try {
for (const host of ['query1', 'query2']) { const hist = await fetchYahooHistory(symbol, range, interval, null);
try { if (hist.points.length >= 2) return hist;
const url = `https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}` } catch (e) {
+ `?range=${range}&interval=${interval}&includeAdjustedClose=true`; const fallback = await fetchNasdaq(symbol, range).catch(() => null);
const d = await jget(url); if (fallback) return fallback;
const r = d?.chart?.result?.[0]; throw e;
if (!r || !Array.isArray(r.timestamp)) { lastErr = new Error('Yahoo 無歷史資料'); continue; }
const ts = r.timestamp;
const close = r.indicators?.quote?.[0]?.close || [];
const adj = r.indicators?.adjclose?.[0]?.adjclose || [];
const points = [];
for (let i = 0; i < ts.length; i++) {
const c = close[i];
if (c == null) continue; // 跳過缺值(停牌/未成交)
const a = (adj[i] != null) ? adj[i] : c;
points.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), close: c, adjclose: a });
}
if (points.length < 2) { lastErr = new Error('歷史資料點過少'); continue; }
return {
symbol: r.meta?.symbol || symbol,
name: r.meta?.shortName || r.meta?.longName || null,
currency: r.meta?.currency || null,
range, interval, source: 'Yahoo Finance',
points,
};
} catch (e) { lastErr = e; }
} }
// Yahoo 失敗(常見 429→ 改用 Nasdaq 免金鑰歷史 throw new Error('歷史資料點過少');
const fallback = await fetchNasdaq(symbol, range).catch(() => null); }
if (fallback) return fallback;
throw lastErr || new Error('無法取得歷史股價'); export async function getHistorySince(symbol, fromISO, range = 'max', interval = '1d') {
if (!INTERVALS.includes(interval)) interval = '1d';
const start = new Date(fromISO);
if (isNaN(start)) throw new Error('起始日期不正確');
const since = new Date(start.getTime() - 3 * 86400000).toISOString().slice(0, 10);
try {
return await fetchYahooHistory(symbol, range, interval, since);
} catch {
const fallback = await fetchNasdaq(symbol, range, since).catch(() => null);
if (fallback) return fallback;
}
throw new Error('無法取得增量歷史股價');
} }

149
server.js
View File

@ -23,12 +23,16 @@ import {
getCachedJSON, putCachedJSON, getCachedEntry, getCachedJSON, putCachedJSON, getCachedEntry,
} from './lib/db.js'; } from './lib/db.js';
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js'; import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
import { getFundamentals, getLatestFilingInfo } from './lib/fundamentals.js'; import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildReport } from './lib/fincheck.js'; import { buildReport } from './lib/fincheck.js';
import { getHistory, RANGES, INTERVALS } from './lib/marketdata.js'; import { getHistory, getHistorySince, RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js'; import { runBacktest, STRATEGIES } from './lib/backtest.js';
import { getInvestMap } from './lib/investmap.js'; import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js'; import { buildGraph } from './lib/graph.js';
import {
getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache,
} from './lib/calendar-cache.js';
import { getCompanyIntel } from './lib/companyintel.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@ -42,6 +46,10 @@ const FUND_SOFT_MS = (Number(process.env.FUND_SOFT_HOURS) || 12) * 3600 * 1000;
const FUND_HARD_MS = (Number(process.env.FUND_HARD_DAYS) || 3) * 24 * 3600 * 1000; const FUND_HARD_MS = (Number(process.env.FUND_HARD_DAYS) || 3) * 24 * 3600 * 1000;
// 歷史股價快取:日線 6 小時內沿用、週/月線 1 天內沿用(節省 API // 歷史股價快取:日線 6 小時內沿用、週/月線 1 天內沿用(節省 API
const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000; const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000;
// 近即時報價:免費來源可能延遲,短快取避免切換畫面時密集連打。
const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1000;
const PROFILE_TTL_MS = (Number(process.env.PROFILE_TTL_HOURS) || 24) * 3600 * 1000;
const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000;
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/; const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here'; const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
@ -150,13 +158,14 @@ app.get('/api/fundamentals/:symbol', async (req, res) => {
const entry = getCachedEntry(cacheKey); // { value, updatedAt } | null const entry = getCachedEntry(cacheKey); // { value, updatedAt } | null
try { try {
if (!fresh && entry) { if (!fresh && entry) {
const hasMetricPayload = entry.value?._metricsVersion >= 2 && (Array.isArray(entry.value?.quarters) || Array.isArray(entry.value?.annual));
const age = Date.now() - entry.updatedAt; const age = Date.now() - entry.updatedAt;
// 1) 還很新 → 直接用快取,完全不連網 // 1) 還很新 → 直接用快取,完全不連網
if (age < FUND_SOFT_MS) return res.json({ ...entry.value, cached: true }); if (hasMetricPayload && age < FUND_SOFT_MS) return res.json({ ...entry.value, cached: true });
// 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報 // 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報
const probe = await getLatestFilingInfo(symbol).catch(() => null); const probe = await getLatestFilingInfo(symbol).catch(() => null);
const known = entry.value._latestFiling; const known = entry.value._latestFiling;
const noUpdate = probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS); const noUpdate = hasMetricPayload && (probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS));
if (noUpdate) { if (noUpdate) {
// 沒有新財報(或暫時無法判斷但還沒到硬上限)→ 續用快取,只更新「檢查時間」 // 沒有新財報(或暫時無法判斷但還沒到硬上限)→ 續用快取,只更新「檢查時間」
const v = { ...entry.value, _checkedAt: Date.now() }; const v = { ...entry.value, _checkedAt: Date.now() };
@ -172,6 +181,11 @@ app.get('/api/fundamentals/:symbol', async (req, res) => {
const payload = { const payload = {
symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source, symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report, currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
sharesOutstanding: fundamentals.sharesOutstanding,
targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
_metricsVersion: 2,
_fetchedAt: now, _checkedAt: now, _fetchedAt: now, _checkedAt: now,
_latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null, _latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
}; };
@ -186,18 +200,41 @@ app.get('/api/fundamentals/:symbol', async (req, res) => {
}); });
// ─── 歷史股價(價格走勢 + 回測共用,持久 DB 快取)─── // ─── 歷史股價(價格走勢 + 回測共用,持久 DB 快取)───
const PRICE_RANGE_DAYS = { '3mo': 92, '6mo': 184, '1y': 370, '2y': 740, '5y': 1855, '10y': 3710, max: null };
function trimHistoryRange(payload, range) {
if (!payload?.points || !PRICE_RANGE_DAYS[range]) return payload;
const since = new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10);
return { ...payload, points: payload.points.filter(p => p.date >= since) };
}
function mergeHistory(oldPayload, patchPayload) {
const map = new Map();
for (const p of (oldPayload.points || [])) map.set(p.date, p);
for (const p of (patchPayload.points || [])) map.set(p.date, p);
const points = [...map.values()].sort((a, b) => a.date < b.date ? -1 : 1);
return {
...oldPayload,
...patchPayload,
points,
_lastIncrementalAt: Date.now(),
_incremental: true,
};
}
async function getHistoryCached(symbol, range, interval, fresh) { async function getHistoryCached(symbol, range, interval, fresh) {
const key = `hist:${symbol}:${range}:${interval}`; const key = `hist:${symbol}:${range}:${interval}`;
const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000; const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000;
const entry = getCachedEntry(key); const entry = getCachedEntry(key);
if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...entry.value, cached: true }; if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...trimHistoryRange(entry.value, range), cached: true };
try { try {
const hist = await getHistory(symbol, range, interval); let hist;
const oldPoints = entry?.value?.points || [];
const lastDate = oldPoints.length ? oldPoints[oldPoints.length - 1].date : null;
if (lastDate) hist = mergeHistory(entry.value, await getHistorySince(symbol, lastDate, range, interval));
else hist = await getHistory(symbol, range, interval);
const payload = { ...hist, _fetchedAt: Date.now() }; const payload = { ...hist, _fetchedAt: Date.now() };
putCachedJSON(key, payload); putCachedJSON(key, payload);
return { ...payload, cached: false }; return { ...trimHistoryRange(payload, range), cached: false };
} catch (err) { } catch (err) {
if (entry) return { ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) }; if (entry) return { ...trimHistoryRange(entry.value, range), cached: true, stale: true, fetchError: String(err?.message || err) };
throw err; throw err;
} }
} }
@ -216,6 +253,99 @@ app.get('/api/price/:symbol', async (req, res) => {
} }
}); });
app.get('/api/quote/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `quote:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < QUOTE_TTL_MS) {
return res.json({ ...entry.value, cached: true });
}
const quote = await getQuote(symbol);
const payload = { symbol, ...quote, _fetchedAt: Date.now() };
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/quote]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'quote_failed', message: String(err?.message || err) });
}
});
app.get('/api/profile/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `profile:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < PROFILE_TTL_MS) return res.json({ ...entry.value, cached: true });
const profile = await getCompanyProfile(symbol);
const payload = { ...profile, _fetchedAt: Date.now() };
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/profile]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'profile_failed', message: String(err?.message || err) });
}
});
app.get('/api/company-intel/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `intel:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS) return res.json({ ...entry.value, cached: true });
const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
const payload = await getCompanyIntel(symbol, profile);
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/company-intel]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'intel_failed', message: String(err?.message || err) });
}
});
function addDaysISO(base, days) {
const d = new Date(base + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function calendarSymbols(req) {
const fromQuery = String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
return [...new Set(fromQuery)].filter(s => SYMBOL_RE.test(s)).slice(0, 30);
}
app.get('/api/calendar/watchlist', (req, res) => {
res.json({ symbols: getCalendarWatchlist() });
});
app.put('/api/calendar/watchlist', (req, res) => {
const raw = Array.isArray(req.body?.symbols) ? req.body.symbols : String(req.body?.symbols || '').split(',');
const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase())));
res.json({ ok: true, symbols });
});
app.get('/api/calendar', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
const start = /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : today;
const end = /^\d{4}-\d{2}-\d{2}$/.test(req.query.end) ? req.query.end : addDaysISO(today, 60);
const fromQuery = calendarSymbols(req);
const stored = getCalendarWatchlist();
const symbols = fromQuery.length ? fromQuery : stored;
const forceFresh = req.query.fresh === '1';
try {
const payload = await getCalendarPayload({ start, end, symbols, forceFresh });
res.json(payload);
} catch (err) {
console.error('[api/calendar]', err?.message || err);
res.status(502).json({ error: 'calendar_failed', message: String(err?.message || err) });
}
});
app.get('/api/backtest/:symbol', async (req, res) => { app.get('/api/backtest/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase(); const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' }); if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
@ -297,4 +427,7 @@ app.listen(PORT, () => {
console.log(`資料就緒:${ok} 個指標,健康分數 ${payload.score}(耗時 ${((Date.now() - t0) / 1000).toFixed(0)} 秒)\n`); console.log(`資料就緒:${ok} 個指標,健康分數 ${payload.score}(耗時 ${((Date.now() - t0) / 1000).toFixed(0)} 秒)\n`);
}) })
.catch((err) => console.log('背景抓取失敗(開啟頁面時會再試):', String(err?.message || err), '\n')); .catch((err) => console.log('背景抓取失敗(開啟頁面時會再試):', String(err?.message || err), '\n'));
warmCalendarCache()
.then(() => console.log('日曆快取已就緒(資料庫,每日更新)。'))
.catch(err => console.warn('[calendar warm]', err?.message || err));
}); });