update dashboard
This commit is contained in:
parent
ec9ea36610
commit
aa38208fff
423
app.css
423
app.css
|
|
@ -24,8 +24,9 @@ body:not([data-view="macro"]) #navLinks{display:none}
|
||||||
/* ── 頁面 ── */
|
/* ── 頁面 ── */
|
||||||
.page{margin:28px 32px 0;animation:fadeInUp .35s ease both}
|
.page{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);
|
||||||
|
|
|
||||||
238
index.html
238
index.html
|
|
@ -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>:把下方關鍵指標用透明公式加總成 0–100 分,越高代表環境對風險性資產越友善(滑到分數上看計算明細)。</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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(/"/g, '"').replace(/'/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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// calendar.js — 重大事件日曆(免費/官方來源優先)
|
||||||
|
// 來源:
|
||||||
|
// - Federal Reserve FOMC calendar(利率決議、SEP/點陣圖、會議紀要)
|
||||||
|
// - BLS 官方 iCalendar(CPI、就業、PPI、JOLTS 等)
|
||||||
|
// - BEA release schedule(GDP、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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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} 個工作(千人=千人,200K=20 萬人)。`,
|
||||||
|
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}(100bp=1%)。`;
|
||||||
|
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 '已進入值得留意的警戒區(常見閾值 25~30%)。';
|
||||||
|
return '目前模型認為衰退機率不算高。';
|
||||||
|
}
|
||||||
|
if (ind.key === 'yield_spread' && value < 0) return '曲線倒掛中——歷史上常領先衰退約 6~18 個月。';
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,免 crumb;query1 失敗改 query2)───
|
// ─── 現價(Yahoo chart v8,免 crumb;query1 失敗改 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 || {},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
// 白話名詞解釋 — 供 ? 浮動卡片使用(國高中生也看得懂)
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERM_TIPS = {
|
||||||
|
market_cap: {
|
||||||
|
label: '市值',
|
||||||
|
what: '把這家公司所有股票加起來,現在市場願意付多少錢。就像整間店「全部股份」的標價。',
|
||||||
|
how: '市值大通常代表公司規模大、影響力大,但不代表一定貴或一定好賺。',
|
||||||
|
},
|
||||||
|
ev: {
|
||||||
|
label: '企業價值 EV',
|
||||||
|
what: '如果要「整間買下來」,大概要花多少。算法是:市值 + 欠的債 - 手邊現金。',
|
||||||
|
how: '比只看市值更公平,因為有些公司欠很多債、有些現金很多,真正價值會不一樣。',
|
||||||
|
},
|
||||||
|
pe: {
|
||||||
|
label: '市盈率 P/E',
|
||||||
|
what: '你願意用「公司一年淨利」的幾倍價格來買。P/E=20 代表:公司一年賺 1 元,你願意花 20 元買。',
|
||||||
|
how: '數字高=大家期待它未來賺更多;數字低=比較便宜或大家不看好。要搭配成長速度一起看。',
|
||||||
|
example: '一年淨利 5 元、股價 100 元 → P/E=20 倍。',
|
||||||
|
},
|
||||||
|
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: '最近漲跌力道強不強的指標,0~100 分。算的是「漲的日子 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: '看長期(1~5 年)比只看最近一個月更能反映趨勢。',
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
149
server.js
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue