diff --git a/.gitignore b/.gitignore index f854a44..78aa8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules/ .DS_Store data.db data.db-* +archive/ .gstack/ diff --git a/app.css b/app.css index 66d5fde..b48ab74 100644 --- a/app.css +++ b/app.css @@ -18,6 +18,8 @@ } .view[hidden]{display:none} +.view{min-width:0} +#main{min-width:0;overflow-x:clip} body[data-view="macro"] #navLinks{display:flex} body:not([data-view="macro"]) #navLinks{display:none} @@ -485,7 +487,7 @@ body:not([data-view="macro"]) #navLinks{display:none} } .slb-head{display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:12px} .slb-head b{font-size:.9rem}.slb-head span{font-size:.74rem;color:var(--text2)} -.slb-steps{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px} +.slb-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px} .slb-steps button{ text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:11px 12px; color:var(--text);font:inherit;cursor:pointer;min-height:86px;transition:.15s; @@ -499,7 +501,7 @@ body:not([data-view="macro"]) #navLinks{display:none} .sub-tabs{ display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:12px; - padding:4px;margin:8px 0 20px;flex-wrap:wrap;width:fit-content; + padding:4px;margin:8px 0 20px;flex-wrap:wrap;width:100%;max-width:100%; } .sub-tabs a{ padding:10px 20px;border-radius:10px;font-size:.86rem;font-weight:600; @@ -508,6 +510,8 @@ body:not([data-view="macro"]) #navLinks{display:none} .sub-tabs a:hover{color:var(--text)} .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} +#view-stock,#stkBody,.stk-pane{min-width:0;width:100%;max-width:100%;overflow-x:clip;box-sizing:border-box} +#view-stock .page{max-width:100%;overflow-x:clip} .metric-head{ display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap; @@ -542,7 +546,8 @@ body:not([data-view="macro"]) #navLinks{display:none} 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{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap;min-width:0} +.metric-section-head span{flex:1 1 100%;min-width:0;word-break:break-word;line-height:1.45} .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} @@ -636,6 +641,87 @@ body:not([data-view="macro"]) #navLinks{display:none} cursor:pointer;font-size:1rem;line-height:1;padding:0;flex-shrink:0; } .watch-chip-x:hover{background:var(--red);color:#fff} + +/* ── 追蹤個股(分群)── */ +.watch-page .watch-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px} +.watch-status{font-size:.72rem;color:var(--text2);flex:1;min-width:120px} +.watch-msg{font-size:.76rem;padding:8px 12px;border-radius:10px;margin-bottom:10px;line-height:1.45} +.watch-msg.good{background:rgba(31,157,102,.08);color:var(--green);border:1px solid rgba(31,157,102,.2)} +.watch-msg.warn{background:rgba(200,138,29,.08);color:var(--orange);border:1px solid rgba(200,138,29,.2)} +.watch-msg.bad{background:rgba(216,79,69,.08);color:var(--red);border:1px solid rgba(216,79,69,.2)} +.watch-layout{ + display:grid;grid-template-columns:minmax(200px,240px) minmax(0,1fr);gap:14px;align-items:start; +} +.watch-groups{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px; + box-shadow:var(--shadow);min-width:0;position:sticky;top:72px; +} +.watch-groups-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px} +.watch-groups-head b{font-size:.88rem} +.watch-group-list{list-style:none;display:grid;gap:6px;margin:0;padding:0} +.watch-group-list li{display:flex;align-items:stretch;gap:4px;min-width:0} +.watch-group-item{ + flex:1;min-width:0;display:flex;justify-content:space-between;align-items:center;gap:8px; + padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:#f9faf7; + cursor:pointer;font-family:inherit;text-align:left;color:var(--text);transition:.15s; +} +.watch-group-item:hover{border-color:rgba(35,103,199,.35)} +.watch-group-item.active{background:rgba(35,103,199,.08);border-color:rgba(35,103,199,.45);box-shadow:0 2px 8px rgba(35,103,199,.1)} +.watch-group-name{font-size:.82rem;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.watch-group-count{font-size:.7rem;color:var(--text2);background:#fff;border:1px solid var(--border);border-radius:999px;padding:2px 8px;flex-shrink:0} +.watch-group-del{ + width:32px;border:1px solid var(--border);background:#fff;border-radius:10px;color:var(--text2); + cursor:pointer;font-size:1rem;line-height:1;flex-shrink:0; +} +.watch-group-del:hover{border-color:var(--red);color:var(--red)} +.watch-main{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px; + box-shadow:var(--shadow);min-width:0; +} +.watch-main-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;flex-wrap:wrap} +.watch-main-title{font-size:1.05rem;font-weight:820;margin:0;line-height:1.25} +.watch-main-sub{display:block;font-size:.72rem;color:var(--text2);margin-top:4px} +.watch-add-form{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px} +.watch-add-form input{ + flex:1;min-width:140px;padding:10px 12px;border:1px solid var(--border);border-radius:10px; + font-size:.86rem;font-family:inherit; +} +.watch-symbol-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:10px} +.watch-empty-panel{ + grid-column:1/-1;padding:28px 16px;text-align:center;font-size:.82rem;color:var(--text2); + background:#f9faf7;border:1px dashed var(--border);border-radius:12px;line-height:1.55; +} +.watch-sym-card{ + border:1px solid var(--border);border-radius:12px;background:#f9faf7;overflow:hidden; + display:flex;flex-direction:column;min-width:0; +} +.watch-sym-open{ + flex:1;width:100%;padding:12px 14px;border:none;background:transparent;cursor:pointer; + text-align:left;font-family:inherit;color:var(--text);display:grid;gap:4px;min-width:0; +} +.watch-sym-open:hover{background:rgba(35,103,199,.05)} +.watch-sym-ticker{font-size:1rem;font-weight:900;color:var(--blue);letter-spacing:.02em} +.watch-sym-name{font-size:.68rem;color:var(--text2);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-height:1em} +.watch-sym-price{font-size:1.12rem;font-weight:800;margin-top:4px} +.watch-sym-chg{font-size:.78rem;font-weight:700} +.watch-sym-actions{ + display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;border-top:1px solid var(--border); + background:#fff;font-size:.7rem; +} +.watch-sym-move-wrap{display:flex;align-items:center;gap:4px;color:var(--text2)} +.watch-sym-move{ + border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:.7rem;font-family:inherit; + max-width:120px; +} +.watch-sym-rm{ + margin-left:auto;border:none;background:transparent;color:var(--text2);cursor:pointer; + font-size:.72rem;font-weight:700;font-family:inherit;padding:4px 6px;border-radius:6px; +} +.watch-sym-rm:hover{color:var(--red);background:rgba(216,79,69,.08)} +@media(max-width:820px){ + .watch-layout{grid-template-columns:1fr} + .watch-groups{position:static} +} .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)} @@ -736,7 +822,13 @@ body.cal-modal-open{overflow:hidden} .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} +#pane-price{min-width:0;overflow-x:clip} +.stock-detail-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.85fr);gap:14px;align-items:start} +.stock-detail-layout > *{min-width:0} +#priceChart{width:100%;max-width:100%;min-width:0;overflow:hidden} +#priceChart .chart-root,#priceChart .chart-stage,#priceChart .chart-wrap,#priceChart .chart-area{width:100%;max-width:100%;min-width:0;box-sizing:border-box} +#priceChart .chart-wrap svg{width:100%;max-width:100%;height:auto;display:block} +#priceChart .chart-legend{max-width:100%} .company-profile{ background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow); } @@ -754,25 +846,119 @@ body.cal-modal-open{overflow:hidden} .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} +.company-intel{display:grid;gap:14px;margin-top:14px;width:100%;max-width:100%;min-width:0;box-sizing:border-box} +.company-intel .intel-section{min-width:0} +.sec-archive-body{display:grid;gap:12px} +.sec-archive-block h4{margin:0 0 8px;font-size:.92rem;color:var(--text)} +.sec-archive-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px} +.sec-filing-list,.sec-earn-list{display:grid;gap:8px} +.sec-filing-row,.sec-earn-row{display:grid;gap:8px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--card)} +@media(min-width:720px){.sec-filing-row{grid-template-columns:1fr auto;align-items:start}} +.sec-filing-row--earn{border-color:rgba(59,130,246,.35)} +.sec-filing-main b{display:block;font-size:.95rem} +.sec-filing-form{font-size:.75rem;color:var(--muted);margin-left:6px} +.sec-filing-main small{display:block;color:var(--muted);margin-top:4px} +.sec-filing-main p,.sec-earn-row p{margin:6px 0 0;font-size:.82rem;color:var(--muted);line-height:1.45} +.sec-filing-excerpt{font-size:.78rem!important;opacity:.9} +.sec-filing-links{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.sec-filing-links a{font-size:.8rem} +.sec-missing{font-size:.75rem;color:var(--muted)} +.sec-earn-row small{display:block;color:var(--muted);margin-top:4px} .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} +.intel-sync-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px;padding:10px 12px;background:#f4f6f2;border:1px solid var(--border);border-radius:12px} +.intel-sync-bar span{font-size:.72rem;color:var(--text2);line-height:1.4;min-width:0;word-break:break-word} +.intel-health-notes{margin:0 0 8px;padding:8px 12px 8px 28px;font-size:.72rem;color:var(--orange);background:rgba(200,138,29,.08);border:1px solid rgba(200,138,29,.2);border-radius:10px;line-height:1.5} +.intel-profile-text{margin:0;font-size:.82rem;line-height:1.6;color:var(--text);word-break:break-word} +.chain-map{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:start;width:100%;max-width:100%} +.chain-map--2{grid-template-columns:repeat(2,minmax(0,1fr))} +.chain-col--down{border-color:rgba(52,199,89,.22);background:#f6fbf7} +.chain-col--up{border-color:rgba(0,113,227,.18)} +.chain-excerpt{font-size:.78rem;color:var(--text2);line-height:1.55;margin:10px 0 0;word-break:break-word} +.intel-resource-links{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px} +.intel-resource-links a{ + font-size:.78rem;padding:6px 12px;border-radius:999px;border:1px solid var(--border); + background:var(--surface);color:var(--blue);text-decoration:none;max-width:100%; +} +.intel-resource-links a:hover{border-color:rgba(0,113,227,.35)} +.chain-map .chain-col{ + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;display:grid;gap:7px; + min-width:0;max-width:100%;overflow-x:hidden;overflow-y:auto;max-height:min(420px,70vh);align-content:start; +} +.chain-group--peers em{color:var(--blue)} +.chain-col--mid{border-color:rgba(35,103,199,.28);background:#f6f9ff} .chain-map b{font-size:.82rem} -.chain-map span{font-size:.72rem;color:var(--text2);line-height:1.35} +.chain-map span{font-size:.72rem;color:var(--text2);line-height:1.35;word-break:break-word} +.chain-mid-role{display:block;font-weight:700;color:var(--blue);margin-top:4px} +.chain-chips{display:flex;flex-wrap:wrap;gap:6px;width:100%;max-width:100%;min-width:0} +.chain-chips span,.chain-chip-static{ + font-size:.7rem;padding:4px 8px;border-radius:999px;background:#fff;border:1px solid var(--border); + line-height:1.25;max-width:100%;word-break:break-word;overflow-wrap:anywhere;display:inline-block; + box-sizing:border-box;vertical-align:top; +} +.chain-chip-btn{ + font-size:.7rem;padding:4px 10px;border-radius:999px;background:#fff;border:1px solid var(--border); + line-height:1.25;max-width:100%;word-break:break-word;overflow-wrap:anywhere;color:var(--blue);font-weight:800; + cursor:pointer;font-family:inherit;transition:border-color .15s,background .15s; + box-sizing:border-box;flex:0 1 auto;min-width:0;vertical-align:top; +} +.chain-chip-btn:hover{border-color:rgba(0,113,227,.4);background:rgba(0,113,227,.06)} +.peer-chips{width:100%;max-width:100%;min-width:0} +.peer-chips button{max-width:100%;overflow-wrap:anywhere;word-break:break-word;flex:0 1 auto;min-width:0} +.chain-group{display:grid;gap:5px;margin-bottom:8px} +.chain-group em{font-style:normal;font-size:.68rem;color:var(--muted);font-weight:700} +.chain-group small{font-size:.65rem;color:var(--text2)} +.intel-section--news{min-width:0;overflow:hidden} +.intel-section--news .metric-section-head{align-items:flex-start;flex-direction:column;gap:4px;margin-bottom:10px} +.intel-section--news .metric-section-head span{flex:none;width:100%} +.news-tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap} +.news-tab{ + border:1px solid var(--border);background:#fff;border-radius:999px;padding:6px 14px; + font-size:.74rem;font-weight:800;cursor:pointer;color:var(--text2);font-family:inherit; + flex:0 1 auto;white-space:nowrap; +} +.news-tab.active{background:var(--blue);border-color:var(--blue);color:#fff} +.news-panel{min-width:0;width:100%} +.news-panel.hidden{display:none} +.news-list{display:flex;flex-direction:column;gap:10px;width:100%;min-width:0} +.news-empty{ + padding:20px 14px;text-align:center;font-size:.8rem;color:var(--text2); + background:#f9faf7;border:1px dashed var(--border);border-radius:12px; +} +.mgmt-brief-list{display:grid;gap:8px} +.mgmt-brief-row{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:#f9faf7} +.mgmt-brief-row.good{border-left:4px solid var(--green)} +.mgmt-brief-row.bad{border-left:4px solid var(--red)} +.mgmt-brief-row.warn{border-left:4px solid var(--orange)} +.mgmt-brief-row b{display:block;font-size:.82rem;line-height:1.35;word-break:break-word} +.mgmt-brief-row small{display:block;color:var(--text2);margin-top:4px;font-size:.68rem} +.mgmt-brief-row p{margin:6px 0 0;font-size:.76rem;line-height:1.45;color:var(--text2);word-break:break-word} +.mgmt-brief-row a{font-size:.72rem} .chain-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} -.chain-links a,.peer-chips button{ +.chain-links a,.peer-chips button,.chain-chip-btn{ 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-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),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} +.profile-desc-note{margin:4px 0 0;font-size:.68rem;color:var(--text2);line-height:1.4} +.intel-notes{margin:0 0 10px;font-size:.8rem;line-height:1.55;color:var(--text)} +.intel-section--custom{background:#fafbf9} +.intel-custom-hint{margin:0 0 8px;font-size:.74rem;color:var(--text2);line-height:1.5} +.intel-custom-hint code{font-size:.7rem;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:4px} +.intel-custom-json{ + width:100%;box-sizing:border-box;font-family:ui-monospace,monospace;font-size:.72rem; + line-height:1.45;padding:10px 12px;border:1px solid var(--border);border-radius:10px; + background:#fff;resize:vertical;min-height:140px; +} +.intel-custom-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px} +.intel-custom-status{font-size:.72rem;color:var(--text2)} +.news-card-en{display:block;font-size:.68rem;color:var(--text2);margin-top:2px;line-height:1.35;word-break:break-word} .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} @@ -789,12 +975,29 @@ body.cal-modal-open{overflow:hidden} .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} +.news-card{ + display:flex;flex-direction:column;gap:6px;min-width:0;width:100%;box-sizing:border-box; + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px 14px; + color:var(--text);text-decoration:none;transition:border-color .15s,box-shadow .15s; +} +.news-card:hover{border-color:rgba(35,103,199,.35);box-shadow:0 2px 10px rgba(35,103,199,.08)} +.news-card-title{ + font-size:.84rem;font-weight:800;line-height:1.4;color:var(--text); + word-break:break-word;overflow-wrap:anywhere; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; +} +.news-card-meta{display:block;font-size:.68rem;color:var(--text2);line-height:1.35} +.news-card-summary{ + margin:0;font-size:.72rem;color:var(--text2);line-height:1.5; + word-break:break-word;overflow-wrap:anywhere; + display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden; +} +@media(max-width:960px){ + .stock-detail-layout{grid-template-columns:1fr} +} +@media(max-width:820px){ + .chain-map,.chain-map--2{grid-template-columns:1fr} +} @media(max-width:520px){ .metric-grid{grid-template-columns:1fr 1fr} .metric-card{min-height:116px} @@ -810,25 +1013,27 @@ body.cal-modal-open{overflow:hidden} .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} .slb-steps{grid-template-columns:1fr 1fr} } +.chart-root{display:flex;flex-direction:column;gap:8px;min-width:0;width:100%} +.chart-stage{position:relative;min-width:0;width:100%} .chart-wrap{ position:relative;width:100%;background:var(--surface); border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow); } .chart-wrap svg{display:block;width:100%;height:auto} .chart-empty{padding:48px 0;text-align:center;color:var(--text2)} -.chart-legend{display:flex;gap:16px;font-size:.78rem;color:var(--text2);margin-bottom:8px} -.chart-legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px} -.chart-hover{font-size:.8rem;color:var(--text2);margin-top:8px;min-height:1.2em} -.range-btns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px} +.chart-legend{display:flex;flex-wrap:wrap;gap:8px 16px;font-size:.78rem;color:var(--text2);margin-bottom:4px} +.chart-legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;vertical-align:middle} +.chart-hover{font-size:.8rem;color:var(--text2);margin-top:8px;min-height:1.2em;line-height:1.45} +.range-btns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;max-width:100%;min-width:0} .range-btns button{ background:var(--surface);border:1px solid var(--border);color:var(--text2); - border-radius:10px;padding:8px 16px;font-size:.82rem;font-weight:600; + border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600; cursor:pointer;font-family:inherit;box-shadow:var(--shadow);transition:.15s; + flex:0 1 auto;max-width:100%;min-width:0; } .range-btns button:hover{border-color:var(--blue)} .range-btns button.active{background:var(--blue);border-color:var(--blue);color:#fff} @@ -1050,20 +1255,57 @@ body.cal-modal-open{overflow:hidden} @media(max-width:600px){ .form-grid{grid-template-columns:1fr} } /* AI Provider / Page Assistant */ -.ai-settings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px} +.ai-settings-grid{display:grid;grid-template-columns:1fr;gap:14px;min-width:0} +@media(min-width:720px){.ai-settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} .ai-provider-card{ background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px; - box-shadow:var(--shadow); + box-shadow:var(--shadow);min-width:0;max-width:100%; +} +.ai-provider-head{ + display:flex;flex-wrap:wrap;justify-content:space-between;gap:10px 14px; + align-items:flex-start;margin-bottom:14px; +} +.ai-provider-head>div:first-child{flex:1 1 12rem;min-width:0;max-width:100%} +.ai-provider-head b{display:block;font-size:1rem} +.ai-provider-head span{display:block;font-size:.76rem;color:var(--text2);line-height:1.55;margin-top:5px;overflow-wrap:anywhere} +.ai-default{ + font-size:.78rem;color:var(--text2);display:flex;gap:6px;align-items:center; + flex:0 0 auto;padding:6px 10px;border-radius:10px;background:#f9faf7;border:1px solid var(--border); +} +.ai-default input{accent-color:var(--blue);flex-shrink:0} +.ai-model-row{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch;width:100%;min-width:0} +.ai-model-row input{min-width:0;flex:1 1 12rem;width:100%;max-width:100%} +.ai-model-row button{flex:0 0 auto;align-self:stretch} +.ai-provider-foot{ + display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px; + margin-top:12px;font-size:.74rem;color:var(--text2); +} +.ai-provider-foot>span{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;line-height:1.5} +@media(min-width:520px){.ai-provider-foot>span{flex:1 1 auto}} +.ai-settings-msg{ + font-size:.82rem;color:var(--text2);margin-top:12px;line-height:1.55; + max-width:100%;overflow-wrap:anywhere;word-break:break-word; +} +.ai-settings-msg .md,.ai-settings-msg pre{max-width:100%;overflow-x:auto} +.ai-settings-msg .md table{display:block;overflow-x:auto} + +/* 設定頁 */ +.settings-page{max-width:1040px;min-width:0} +.settings-page .page-sub{max-width:100%} +.settings-env-path{ + margin-top:10px;padding:10px 12px;font-size:.78rem;line-height:1.5;color:var(--text2); + background:#f9faf7;border:1px solid var(--border);border-radius:10px; + overflow-wrap:anywhere;word-break:break-all;max-width:100%; +} +.settings-page .form-grid{grid-template-columns:1fr} +.settings-page .field input,.settings-page .field textarea{max-width:100%} +.settings-page .env-provider-card{margin-bottom:14px} +.settings-page .form-actions{flex-wrap:wrap} +@media(max-width:479px){ + .settings-page .form-actions .btn{width:100%} + .ai-model-row .btn{width:100%} + .ai-provider-foot .btn{width:100%} } -.ai-provider-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:14px} -.ai-provider-head b{display:block;font-size:1rem}.ai-provider-head span{display:block;font-size:.76rem;color:var(--text2);line-height:1.55;margin-top:5px} -.ai-default{font-size:.78rem;color:var(--text2);display:flex;gap:6px;align-items:center;white-space:nowrap} -.ai-default input{accent-color:var(--blue)} -.ai-model-row{display:flex;gap:8px;align-items:center} -.ai-model-row input{min-width:0;flex:1} -.ai-model-row button{flex-shrink:0} -.ai-provider-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px;font-size:.74rem;color:var(--text2)} -.ai-settings-msg{font-size:.82rem;color:var(--text2);margin-top:12px;line-height:1.55} .ai-dock{position:fixed;right:22px;bottom:22px;z-index:380} .ai-fab{ width:54px;height:54px;border-radius:50%;border:none;background:#202421;color:#fff; @@ -1078,11 +1320,21 @@ body.cal-modal-open{overflow:hidden} .ai-panel-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;border-bottom:1px solid var(--border);padding:14px 14px 10px;background:rgba(249,250,247,.92)} .ai-panel-head b{display:block;font-size:.95rem}.ai-panel-head span{display:block;font-size:.72rem;color:var(--text2);line-height:1.45;margin-top:3px} .ai-panel-head button{border:1px solid var(--border);background:#f9faf7;color:var(--text2);border-radius:8px;width:30px;height:30px;cursor:pointer;font-size:1rem} -.ai-provider-row{display:flex;gap:8px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface)} -.ai-provider-row select{ - flex:1;border:1px solid var(--border);border-radius:10px;background:#f9faf7;color:var(--text); - padding:9px 11px;font:inherit;font-size:.82rem; +.ai-toolbar{ + display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr) auto;gap:8px 10px; + align-items:end;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface); } +.ai-field{display:flex;flex-direction:column;gap:4px;min-width:0} +.ai-field-label{font-size:.66rem;font-weight:600;letter-spacing:.02em;color:var(--text2);text-transform:uppercase} +.ai-field select,.ai-toolbar select{ + width:100%;min-width:0;max-width:100%;border:1px solid var(--border);border-radius:10px; + background:#f9faf7;color:var(--text);padding:8px 10px;font:inherit;font-size:.8rem; + line-height:1.35;appearance:none; + background-image:linear-gradient(45deg,transparent 50%,var(--text2) 50%),linear-gradient(135deg,var(--text2) 50%,transparent 50%); + background-position:calc(100% - 14px) calc(50% - 2px),calc(100% - 9px) calc(50% - 2px); + background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:28px; +} +.ai-toolbar-settings{align-self:end;white-space:nowrap} .ai-chat{ flex:1;overflow:auto;padding:14px;background: linear-gradient(180deg,rgba(35,103,199,.04),rgba(32,40,33,.02)); @@ -1094,8 +1346,12 @@ body.cal-modal-open{overflow:hidden} .ai-bubble{ border:1px solid var(--border);background:#fff;color:var(--text); border-radius:16px;padding:10px 12px;font-size:.86rem;line-height:1.6;box-shadow:0 1px 2px rgba(32,40,33,.05); - overflow-wrap:anywhere; + overflow-wrap:anywhere;word-break:break-word;max-width:100%; } +.ai-bubble pre,.ai-bubble code{max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-all} +.ai-bubble .md{max-width:100%;overflow-x:auto} +.ai-bubble .md table{display:block;max-width:100%;overflow-x:auto} +.ai-bubble .mermaid-wrap{max-width:100%;overflow-x:auto} .ai-msg-user .ai-bubble{background:#2367c7;color:#fff;border-color:#2367c7;border-bottom-right-radius:5px} .ai-msg-bot .ai-bubble{border-bottom-left-radius:5px} .ai-msg-meta{font-size:.66rem;color:var(--text2);margin:4px 6px 0} @@ -1116,4 +1372,349 @@ body.cal-modal-open{overflow:hidden} .ai-typing i:nth-child(2){animation-delay:.15s}.ai-typing i:nth-child(3){animation-delay:.3s} @keyframes aiTyping{0%,80%,100%{transform:translateY(0);opacity:.35}40%{transform:translateY(-3px);opacity:.8}} .ai-error{color:var(--red);background:rgba(216,79,69,.08);border:1px solid rgba(216,79,69,.16);border-radius:10px;padding:10px 12px} -@media(max-width:520px){.ai-dock{right:16px;bottom:16px}.ai-panel{height:min(82vh,680px)}.ai-provider-row,.ai-model-row{align-items:stretch;flex-direction:column}} +@media(max-width:520px){ + .ai-dock{right:16px;bottom:16px} + .ai-panel{width:calc(100vw - 24px);height:min(82vh,680px)} + .ai-toolbar{grid-template-columns:1fr 1fr;grid-template-rows:auto auto} + .ai-toolbar-settings{grid-column:1/-1;justify-self:end} + .ai-settings-grid{grid-template-columns:1fr} +} + +/* ── 技術圖表頁(個股 · 技術圖表分頁)── */ +.ta-page{display:flex;flex-direction:column;gap:16px;min-width:0;max-width:100%} +.ta-hero{ + display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:16px 24px; + padding:18px 20px;background:var(--surface);border:1px solid var(--border);border-radius:16px; + box-shadow:var(--soft-shadow); +} +.ta-hero-title{margin:0;font-size:1.15rem;font-weight:800;line-height:1.3} +.ta-hero-sym{font-size:.82rem;font-weight:700;color:var(--text2);margin-left:6px} +.ta-hero-price{margin:6px 0 0;font-size:1.45rem;font-weight:900;font-variant-numeric:tabular-nums;color:var(--blue)} +.ta-hero-price small{font-size:.78rem;font-weight:600;color:var(--text2);margin-left:8px} +.ta-hero-kpis{display:flex;flex-wrap:wrap;gap:10px} +.ta-hero-kpis div{ + min-width:88px;padding:10px 14px;background:#f9faf7;border:1px solid var(--border);border-radius:12px; +} +.ta-hero-kpis span{display:block;font-size:.68rem;color:var(--text2);font-weight:700;margin-bottom:4px} +.ta-hero-kpis b{font-size:1rem;font-weight:800;font-variant-numeric:tabular-nums} +.ta-hero-kpis small{display:block;font-size:.68rem;color:var(--text2);font-weight:600;margin-top:3px} +.ta-controls{ + display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1fr) minmax(0,1.2fr) auto;gap:12px 16px;align-items:end; + padding:14px 16px;background:var(--surface);border:1px solid var(--border);border-radius:14px; +} +.ta-controls--panels{ + grid-template-columns:1fr;align-items:stretch;margin-top:-4px; +} +.ta-panels-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px} +.ta-panels-row .chip-row{flex:1;min-width:0} +.ta-preset-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} +.ta-preset-label{font-size:.68rem;font-weight:700;color:var(--text2);white-space:nowrap} + +.ta-control-group{display:flex;flex-direction:column;gap:8px;min-width:0} +.ta-control-group--layers{grid-column:span 1} +.ta-label{font-size:.75rem;font-weight:800;color:var(--text);letter-spacing:.02em} +.ta-control-hint{margin:0;font-size:.72rem;color:var(--text2);line-height:1.45} +.ta-refresh{align-self:end;white-space:nowrap} +.ta-section-title{margin:0 0 10px;font-size:.82rem;font-weight:800;color:var(--text2);letter-spacing:.04em} +.ta-chart-card{ + background:var(--surface);border:1px solid var(--border);border-radius:16px; + box-shadow:var(--soft-shadow);min-width:0;overflow:visible; +} +.ta-chart-top{padding:14px 16px 10px;border-bottom:1px solid var(--border);background:#fafbf9} +.ta-meta-chips{display:flex;flex-wrap:wrap;gap:6px} +.ta-chip{ + font-size:.7rem;font-weight:700;color:var(--text2);background:#fff;border:1px solid var(--border); + border-radius:999px;padding:4px 10px;white-space:nowrap; +} +.ta-chip--ok{color:var(--green);border-color:rgba(52,168,83,.35);background:rgba(52,168,83,.08)} +.ta-db-range{margin:8px 0 0;font-size:.72rem;color:var(--text2);line-height:1.4;word-break:break-all} +.ta-legend{ + display:flex;flex-wrap:wrap;gap:8px 14px;padding:10px 16px;border-bottom:1px solid var(--border); + background:#fff;min-height:40px;align-items:center; +} +.ta-leg-item{display:inline-flex;align-items:center;gap:6px;font-size:.76rem;color:var(--text2);font-weight:600;white-space:nowrap} +.ta-leg-item i{width:14px;height:3px;border-radius:2px;flex-shrink:0} +.ta-workflow-hint{ + margin:0;padding:8px 16px;font-size:.72rem;color:var(--text2);line-height:1.5; + border-bottom:1px solid var(--border);background:#fafbf9; +} +/* TradingView 式圖表:Y 軸固定欄 + 可捲動圖區 + 固定時間軸 */ +.tv-chart{border-top:1px solid var(--border);background:#fff;overflow:hidden} +.tv-chart-body{display:flex;position:relative;min-width:0;background:#fafbfc;overflow:hidden} +.tv-y-col{ + flex:0 0 64px;width:64px;min-width:64px;display:flex;flex-direction:column; + border-right:1px solid var(--border);background:#fff;z-index:8; + position:sticky;left:0;align-self:stretch; +} +.tv-y-slot{ + position:relative;flex-shrink:0;border-bottom:1px solid var(--border); + overflow:visible;background:#fff; +} +.tv-y-slot:last-child{border-bottom:none} +.tv-y-slot-label{ + position:absolute;top:0;left:0;right:0;z-index:2; + font-size:9px;font-weight:800;color:var(--text2);text-align:center; + padding:3px 2px;background:#f5f7f4;border-bottom:1px solid var(--border); + pointer-events:none; +} +.tv-y-slot--sub{padding-top:20px;box-sizing:border-box} +.chart-gutter-y__ticks,.tv-scale-tags{ + position:absolute;inset:0;pointer-events:none; +} +.tv-y-slot--sub .chart-gutter-y__ticks,.tv-y-slot--sub .tv-scale-tags{top:20px} +.tv-scale-tags{z-index:4} +.tv-scale-tags--stack{ + display:flex;align-items:flex-start;justify-content:flex-start; + padding:4px 3px 6px;box-sizing:border-box;overflow-y:auto;overflow-x:hidden; + max-height:100%; +} +.tv-scale-stack{ + display:flex;flex-direction:column;gap:5px;width:100%;max-width:100%; +} +.tv-scale-tag--stacked{ + position:relative;transform:none;top:auto;right:auto;left:auto; + display:flex;align-items:center;justify-content:space-between;gap:5px; + width:100%;box-sizing:border-box;padding:4px 6px;min-height:26px; +} +.tv-scale-tag{ + position:absolute;right:3px;transform:translateY(-50%); + display:inline-flex;align-items:center;gap:4px;max-width:calc(100% - 6px); + padding:4px 6px;border-radius:5px;background:var(--tag-bg,#2367c7);color:#fff; + font-size:9px;font-weight:700;line-height:1.3;white-space:nowrap; + box-shadow:0 1px 6px rgba(0,0,0,.18);pointer-events:none; +} +.tv-scale-tags--vol-hover{ + position:absolute;left:0;right:0;bottom:4px;top:auto;height:auto; + z-index:5;pointer-events:none;padding:0 3px; +} +.tv-scale-vol-pin{ + display:flex;flex-direction:column;gap:3px;width:100%; +} +.tv-scale-vol-sub{ + display:block;font-size:8px;font-weight:700;color:var(--text2); + text-align:center;line-height:1.2;padding:0 2px 2px; +} +.chart-gutter-y__ticks:empty{display:none} +.tv-scale-leader{ + position:absolute;right:100%;top:50%; + width:1px;height:var(--drift,8px); + margin-right:2px; + background:rgba(0,0,0,.25); + transform:translateY(calc(-50% + var(--dir,1) * var(--drift,8px) / 2 * -1)); + pointer-events:none; +} +.tv-scale-tag em{font-style:normal;opacity:.92;font-size:8px;flex-shrink:0} +.tv-scale-tag b{font-weight:800;font-variant-numeric:tabular-nums;flex-shrink:0} +.chart-gutter-y__tick{right:2px;z-index:1} +.tv-scroll{ + flex:1;min-width:0;overflow-x:auto;overflow-y:hidden; + cursor:grab;-webkit-overflow-scrolling:touch; +} +.tv-scroll.is-dragging{cursor:grabbing;user-select:none} +.tv-stack{display:flex;flex-direction:column;min-width:max-content} +.tv-pane--main{background:linear-gradient(180deg,#fff 0%,#f8faf9 100%);border-bottom:1px solid var(--border)} +.tv-pane--solo{border-bottom:none} +.tv-sub-panel{position:relative;border-bottom:1px solid var(--border);background:#fff} +.tv-sub-plot{background:#fff} +.ta-sub-close--float{ + position:absolute;top:4px;right:6px;z-index:6; + width:22px;height:22px;border-radius:6px;border:1px solid var(--border); + background:rgba(255,255,255,.92);font-size:1rem;line-height:1; + cursor:pointer;color:var(--text2);font-weight:700; +} +.ta-sub-close--float:hover{background:#fff;color:var(--text)} +.chart-gutter-y{position:relative;width:100%;height:100%} +.chart-gutter-y__tick{ + position:absolute;right:4px;transform:translateY(-50%); + font-size:10px;font-weight:600;color:#5c6562; + font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none; +} +.tv-plot,.chart-plot-area{flex-shrink:0;background:#fff} +.tv-plot .chart-wrap,.tv-plot svg,.chart-plot-area svg{display:block;width:100%;height:100%} +.chart-root--tv .chart-stage,.chart-root--tv .chart-wrap{margin:0;padding:0;border:none;box-shadow:none;background:transparent} +.tv-cursor-y{ + position:absolute;left:2px;z-index:8;transform:translateY(-50%); + padding:2px 6px;border-radius:4px;background:var(--blue);color:#fff; + font-size:10px;font-weight:700;font-variant-numeric:tabular-nums; + pointer-events:none;white-space:nowrap;box-shadow:0 2px 8px rgba(35,103,199,.35); +} +.tv-x-wrap{ + display:flex;position:relative;height:28px; + border-top:1px solid var(--border);background:#fff; +} +.tv-x-pad{flex:0 0 64px;width:64px;min-width:64px;border-right:1px solid var(--border);background:#fafbf9} +.tv-chart{display:block} +.tv-chart-view{display:flex;flex-direction:column;min-width:0} +.tv-chart-foot{ + flex-shrink:0;width:100%;background:#fff; + border-top:1px solid var(--border); + position:sticky;bottom:0;z-index:12; + box-shadow:0 -4px 12px rgba(0,0,0,.06); +} +.ta-readout-wrap--pinned{ + display:block!important;min-height:52px;width:100%;box-sizing:border-box; +} +.tv-y-slot .tv-scale-tags, +.tv-y-slot .tv-scale-tags--vol-hover, +.tv-y-slot .tv-scale-tag{display:none!important} +.tv-x-track{flex:1;position:relative;min-width:0;overflow:hidden} +.tv-x-track .ta-x-axis__tick{top:8px} +.ta-x-axis__tick{ + position:absolute;transform:translateX(-50%); + font-size:10px;font-weight:600;color:#5c6562;white-space:nowrap; + pointer-events:none;font-variant-numeric:tabular-nums; +} +.tv-cursor-x{ + position:absolute;top:3px;z-index:8;transform:translateX(-50%); + padding:2px 8px;border-radius:4px;background:#202421;color:#f5f7f4; + font-size:10px;font-weight:700;pointer-events:none;white-space:nowrap; + box-shadow:0 2px 8px rgba(0,0,0,.2); +} +.tv-readout{min-height:0} +.ta-readout-wrap{ + padding:10px 14px 12px; + background:linear-gradient(180deg,#f6f8fc 0%,#eef2f8 100%); + border-bottom:1px solid var(--border); +} +.ta-readout__chips{ + display:flex;flex-wrap:wrap;gap:8px;align-items:stretch; +} +.ta-readout-chip{ + display:inline-flex;flex-direction:column;gap:3px;min-width:52px;max-width:100%; + padding:7px 11px;background:#fff;border:1px solid rgba(0,0,0,.08); + border-radius:10px;box-shadow:0 1px 2px rgba(0,0,0,.04); + flex:0 1 auto; +} +.ta-readout-chip em{ + font-size:.64rem;font-weight:800;color:var(--text2); + letter-spacing:.04em;font-style:normal;line-height:1.2; +} +.ta-readout-chip b{ + font-size:.84rem;font-weight:800;color:var(--text); + font-variant-numeric:tabular-nums;line-height:1.25;word-break:break-word; +} +.ta-readout-chip small{ + font-size:.64rem;color:var(--text2);line-height:1.3;margin-top:-1px; +} +.ta-readout-chip--date{min-width:108px} +.ta-readout-chip--date b{font-size:.78rem;font-weight:700} +.ta-readout-chip--close b{color:var(--blue)} +.ta-readout-chip--vol-today{border-color:rgba(35,103,199,.25);background:#f5f9ff} +.ta-readout-chip--vol-up{border-color:rgba(200,138,29,.35);background:#fffbf0} +.ta-readout-chip--vol-up b{color:#9a6b12} +.ta-readout-chip--vol-spike{border-color:rgba(216,79,69,.35);background:#fff6f5} +.ta-readout-chip--vol-spike b{color:#b91c1c} +.ta-readout-chip__badge{ + align-self:flex-start;margin-top:2px;padding:2px 7px;border-radius:5px; + font-size:.62rem;font-weight:800;line-height:1.2;letter-spacing:.02em; +} +.ta-readout-chip__badge--elevated{background:#fff6e0;color:#9a6b12} +.ta-readout-chip__badge--spike{background:#fde8e6;color:#b91c1c} +.ta-chip--vol-spike{background:#fde8e6;color:#b91c1c;border:1px solid #f0b4ae;font-weight:800} +.ta-chip--vol-elevated{background:#fff6e0;color:#9a6b12;border:1px solid #ecd9a8;font-weight:800} + +.vol-bar--active{stroke:#202421;stroke-width:1.2} +.vol-bar--spike.vol-bar--active{stroke:#b91c1c} +.ta-glossary-bar{ + display:grid;grid-template-columns:auto 1fr;gap:8px 12px;align-items:center; + padding:10px 14px 12px;border-top:1px solid var(--border);background:#fafbf9; +} +.ta-glossary-title{ + font-size:.72rem;font-weight:800;color:var(--text2);white-space:nowrap; + padding-top:2px; +} +.ta-glossary-chips{ + display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;min-width:0; +} +.ta-glossary-chips .info-btn{margin:0;flex-shrink:0} +.ta-stat-label{ + display:inline-flex;align-items:center;gap:4px; + font-size:.7rem;color:var(--text2);font-weight:700;line-height:1.35; +} +.ta-stat-label .info-btn{margin:0;vertical-align:baseline} +.ta-hero-kpis .ta-stat-label{display:inline-flex} +.chart-row-sticky{display:flex;flex-direction:row;align-items:stretch;width:max-content} +.chart-plot-area{flex-shrink:0} +.ta-sub-panel{border-bottom:1px solid var(--border);background:#fff} +.ta-sub-panel[hidden]{display:none} +.ta-sub-panel-head{ + display:flex;align-items:center;justify-content:space-between;gap:8px; + padding:6px 12px;background:#f5f7f4;border-bottom:1px solid var(--border); +} +.ta-sub-panel-head span{font-size:.72rem;font-weight:800;color:var(--text2);letter-spacing:.02em} +.ta-sub-close{ + border:none;background:transparent;color:var(--text2);font-size:1.1rem;line-height:1; + cursor:pointer;padding:2px 8px;border-radius:6px;font-weight:700; +} +.ta-sub-close:hover{background:rgba(0,0,0,.06);color:var(--text)} +.ta-subchart-body{min-height:88px;overflow:visible} +.ta-subchart-body .chart-row-sticky{border-bottom:none} +.ta-subchart-body svg{display:block;width:100%;height:auto} +.ta-panels-empty{ + padding:20px 16px;text-align:center;font-size:.78rem;color:var(--text2); + border-bottom:1px solid var(--border);background:#fafbfc; +} +.ta-panels-empty[hidden]{display:none} + +.ta-stats-wrap{min-width:0} +.ta-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.ta-stat{ + background:var(--surface);border:1px solid var(--border);border-radius:12px; + padding:12px 14px;min-width:0;box-shadow:var(--shadow); +} +.ta-stat span{display:block;font-size:.7rem;color:var(--text2);font-weight:700;line-height:1.35} +.ta-stat b{display:block;font-size:1.02rem;font-weight:800;margin-top:6px;font-variant-numeric:tabular-nums;word-break:break-word} +.ta-stat small{display:block;font-size:.68rem;color:var(--text2);margin-top:4px;line-height:1.35} +.ta-ai-card{ + background:var(--surface);border:1px solid rgba(35,103,199,.2);border-radius:16px; + padding:18px 20px;min-width:0;box-shadow:var(--soft-shadow); +} +.ta-ai-desc{margin:0 0 12px;font-size:.8rem;color:var(--text2);line-height:1.55} +.ta-ai-actions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px} +.ta-ai-out{ + font-size:.86rem;line-height:1.65;color:var(--text); + background:#f9faf7;border:1px solid var(--border);border-radius:12px; + padding:14px 16px;min-height:80px;max-height:min(40vh,360px); + overflow-y:auto;overflow-x:hidden;min-width:0; +} +.ta-ai-out .ta-ai-md{min-width:0;overflow-wrap:anywhere;word-break:break-word} +.ta-ai-out .md{font-size:.86rem;line-height:1.65;max-width:100%} +.ta-ai-out .md>:first-child{margin-top:0} +.ta-ai-out .md>:last-child{margin-bottom:0} +.ta-ai-out .md h1{font-size:1.05rem;margin:.6em 0 .35em;padding:0;border:none} +.ta-ai-out .md h2{font-size:.98rem;margin:.75em 0 .35em;padding-bottom:.25em} +.ta-ai-out .md h3{font-size:.9rem;margin:.6em 0 .3em} +.ta-ai-out .md h4{font-size:.86rem;margin:.5em 0 .25em} +.ta-ai-out .md p{margin:.45em 0} +.ta-ai-out .md ul,.ta-ai-out .md ol{margin:.4em 0 .5em;padding-left:1.25em} +.ta-ai-out .md li{margin:.25em 0} +.ta-ai-out .md blockquote{ + margin:.5em 0;padding:8px 12px;border-left:3px solid var(--blue); + background:rgba(35,103,199,.06);border-radius:0 8px 8px 0; +} +.ta-ai-out .md pre,.ta-ai-out .md table{display:block;max-width:100%;overflow-x:auto} +.ta-ai-out .md table{margin:.6em 0;font-size:.8rem} +.ta-ai-out .md hr{margin:.8em 0} +.ta-ai-out .ai-error,.ta-ai-out .ai-typing{word-break:break-word} +.chart-root--ta{width:100%;height:100%} +.chart-stage--ta{width:100%;height:100%} +@media(max-width:960px){ + .ta-controls:not(.ta-controls--panels){grid-template-columns:1fr 1fr} + .ta-control-group--layers{grid-column:1/-1} + .ta-controls .ta-control-group:first-child{grid-column:1/-1} + .ta-refresh{grid-column:1/-1;justify-self:start} + .ta-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))} +} +@media(max-width:560px){ + .ta-controls{grid-template-columns:1fr} + .ta-hero-kpis{width:100%} + .ta-hero-kpis div{flex:1} + .ta-stat-grid{grid-template-columns:1fr} + .ta-glossary-bar{grid-template-columns:1fr;gap:6px} + .ta-readout-chip{min-width:46px;padding:6px 9px} + .ta-readout-chip--date{min-width:0;flex:1 1 100%} + .tv-y-col{flex:0 0 72px;width:72px;min-width:72px} + .tv-x-pad{flex:0 0 72px;width:72px;min-width:72px} + .tv-scale-tag{font-size:8px;padding:2px 4px} + .sub-tabs{width:100%;max-width:100%} +} diff --git a/app.js b/app.js index 54f2825..3e6bdd2 100644 --- a/app.js +++ b/app.js @@ -186,7 +186,7 @@ function bindWlinks(container) { // ═══════════════════════════════════════════════════════════ // 主視圖路由 // ═══════════════════════════════════════════════════════════ -const VIEW_IDS = ['macro', 'calendar', 'learn', 'stock', 'journal', 'settings']; +const VIEW_IDS = ['macro', 'calendar', 'watchlist', 'learn', 'stock', 'journal', 'settings']; const inited = {}; function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; } function setAIFocus(focus) { @@ -202,11 +202,13 @@ function setView(view) { VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; }); $$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); if (view === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); } + if (view === 'watchlist' && !inited.watchlist) { inited.watchlist = true; initWatchlist(); } if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); } if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); } if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); } if (view === 'settings' && !inited.settings) { inited.settings = true; initSettings(); } updateAIContextLabel(); + if (!$('#aiPanel')?.hidden) refreshAIContextLabel(); if (view !== 'macro') window.scrollTo({ top: 0 }); } $$('#viewTabs a').forEach(a => a.addEventListener('click', () => { @@ -282,10 +284,11 @@ async function initSettings() { const envSettings = await loadEnvSettings(); const settings = readAISettings(); view.innerHTML = ` -
+
API Key 與 AI Provider 設定
-
所有金鑰會寫入本機專案的 .env:${escapeHtml(envSettings.envPath || '.env')}。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。
+
所有金鑰會寫入本機專案的 .env(路徑見下方)。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。
+
${escapeHtml(envSettings.envPath || '.env')}
@@ -354,53 +357,58 @@ async function initSettings() { } })); } +let aiWidgetBusy = false; + function initAIWidget() { const dock = $('#aiDock'); if (!dock) return; dock.innerHTML = ` -
本面板會優先使用 Yahoo、Nasdaq、SEC 與價格歷史等免費公開來源;近即時報價會短暫快取,歷史資料會留在本機,下次只補新日期。MA、RSI、MACD 與布林通道由本機用日線計算;DCF 公允價值以年度自由現金流折現估算。免費報價可能延遲,交易前仍要對照券商報價。
+
本面板會優先使用 Yahoo、Nasdaq、SEC 與價格歷史等免費公開來源。預測區:分析師共識來自 Yahoo earningsTrend(卡片 ? 內有公式與 endpoint);DCF為 MacroScope 本機模型(? 內列出當次 FCF、成長率、折現率)。無資料時顯示「尚無共識/無法估算」,不填假數字。免費報價可能延遲,交易前請對照券商與官方財報。
${buildMetrics(enriched, pstats, tech, quote, backtests)}
`; $('#metricRefresh').addEventListener('click', async () => { STOCK.rendered.metrics = ''; @@ -1955,14 +3793,38 @@ async function renderPrice(force) { const chg = (last / first - 1) * 100; const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg'; $('#priceHead').innerHTML = `${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 ${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%${d.cached ? ' · 快取' : ''}`; - drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) }); - renderCompanyProfile(profile, d, last); - renderCompanyIntel(STOCK.symbol, profile, force); + const chartEl = $('#priceChart'); + const chartW = chartEl ? Math.min(760, Math.max(280, Math.floor(chartEl.clientWidth || 0))) : 760; + drawLineChart(chartEl, [{ name: '收盤價', color: HEX.blue, points: pts }], { + fmt: v => fmtNum(v, 2), + chartWidth: chartW, + stretch: false, + }); + const intel = await api(`/api/company-intel/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => null); + renderCompanyProfile(profile, d, last, intel); + renderCompanyIntel(STOCK.symbol, profile, force, intel); } catch (e) { pane.querySelector('#priceChart').innerHTML = `
無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; } } -function renderCompanyProfile(profile, priceData, last) { +function marketStatusZh(s) { + const m = { OPEN: '交易中', CLOSED: '已收盤', PRE: '盤前', POST: '盤後', 'PRE-MARKET': '盤前' }; + const k = String(s || '').toUpperCase(); + return m[k] || s || '—'; +} + +function sectorIndustryZh(profile, intel) { + const sector = profile?.sector || intel?.management?.sector; + const industry = profile?.industry || intel?.management?.industry; + const sectorMap = { Technology: '科技', 'Financial Services': '金融服務', Healthcare: '醫療保健', Energy: '能源' }; + const sectorZh = sectorMap[sector] || sector || '—'; + let industryZh = industry || '—'; + if (industry && /semiconductor/i.test(industry)) industryZh = `半導體(${industry})`; + else if (industry && /software/i.test(industry)) industryZh = `軟體(${industry})`; + return { sectorZh, industryZh }; +} + +function renderCompanyProfile(profile, priceData, last, intel) { const box = $('#companyProfile'); if (!box) return; if (!profile) { @@ -1971,27 +3833,30 @@ function renderCompanyProfile(profile, priceData, last) { } const q = profile.quote || {}; const notif = (profile.notifications || []).flatMap(n => n.eventTypes || []).slice(0, 3); + const { sectorZh, industryZh } = sectorIndustryZh(profile, intel); + const desc = intel?.profileZh?.description || profile.descriptionZh || profile.description; + const descNote = intel?.profileZh?.description ? '(已整理為中文)' : (profile.description ? '(原文;同步研究資料後會更新)' : ''); box.innerHTML = `
-
${escapeHtml(profile.name || priceData.name || priceData.symbol)}${escapeHtml(profile.exchange || '')} · ${escapeHtml(profile.marketStatus || '')}
+
${escapeHtml(profile.name || priceData.name || priceData.symbol)}${escapeHtml(profile.exchange || '')} · ${escapeHtml(marketStatusZh(profile.marketStatus))}
${fmtNum(q.price ?? last, 2)}
-
Bid / Ask${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}
-
Sector${escapeHtml(profile.sector || '—')}
-
Industry${escapeHtml(profile.industry || '—')}
-
Region${escapeHtml(profile.region || '—')}
+
買價 / 賣價${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}
+
產業板塊${escapeHtml(sectorZh)}
+
細產業${escapeHtml(industryZh)}
+
地區${escapeHtml(profile.region || '—')}
- ${profile.description ? `

${escapeHtml(profile.description)}

` : ''} + ${desc ? `

${escapeHtml(desc)}

${escapeHtml(descNote)}

` : '

尚無公司簡介,進入本頁會自動抓取。

'}
${profile.website ? `公司網站` : ''} ${profile.address ? `${escapeHtml(profile.address)}` : ''}
- ${notif.length ? `
Upcoming${notif.map(e => `${escapeHtml(e.message || e.eventName || '')}`).join('')}
` : ''} -
公司資訊來自 Nasdaq profile / quote,報價可能延遲。
+ ${notif.length ? `
近期事件${notif.map(e => `${escapeHtml(e.message || e.eventName || '')}`).join('')}
` : ''} +
公司資訊來自 Nasdaq;報價可能延遲。職稱會自動對照常見中文。
`; const rb = $('#intelRefresh'); - if (rb) rb.addEventListener('click', () => renderCompanyIntel(profile.symbol || STOCK.symbol, profile, true)); + if (rb) rb.addEventListener('click', () => runIntelSync(profile.symbol || STOCK.symbol, profile, true)); } function txSignal(t) { if (t.signal === 'acquire') return ['取得', 'good']; @@ -2008,58 +3873,412 @@ function renderCompanyIntelSkeleton() { box.innerHTML = '
正在整理產業鏈、管理層、內部人交易與新聞…
'; return box; } -async function renderCompanyIntel(symbol, profile, fresh) { +const INTEL_CUSTOM_SAMPLE = `{ + "profileZh": { + "description": "公司簡介(中文,自行整理)" + }, + "officers": [ + { "name": "黃仁勳", "title": "President and Chief Executive Officer", "titleZh": "執行長暨總裁" } + ], + "news": [ + { "title": "原文標題", "titleZh": "中文標題", "descriptionZh": "中文摘要", "url": "https://..." } + ], + "managementNotes": "治理與策略備註(選填)" +}`; + +function secArchiveLocalUrl(symbol, accession, localPath) { + if (!localPath) return null; + const file = String(localPath).split('/').pop(); + return `/api/sec-archive/${encodeURIComponent(symbol)}/file?accession=${encodeURIComponent(accession)}&file=${encodeURIComponent(file)}`; +} + +function renderSecArchiveBody(data) { + const body = $('#secArchiveBody'); + const status = $('#secArchiveStatus'); + if (!body) return; + const filings = data.filings || []; + const earnings = data.earnings || []; + const meta = data.meta || {}; + const synced = meta.lastSyncAt ? new Date(meta.lastSyncAt).toLocaleString('zh-TW') : null; + if (status) { + status.textContent = synced + ? `已封存 ${filings.length} 筆申報 · ${earnings.length} 筆財報/法說事件 · 上次同步 ${synced}` + : (filings.length ? `本機已有 ${filings.length} 筆` : '尚未同步,請按「抓取並封存」'); + } + const filingRows = filings.length ? filings.map(f => { + const local = f.localPrimary || f.localTxt; + const localUrl = local ? secArchiveLocalUrl(data.symbol, f.accession, local) : null; + const exhibits = (f.earningsExhibits || []).map(ex => { + const u = ex.localPath ? secArchiveLocalUrl(data.symbol, f.accession, ex.localPath) : ex.url; + return u ? `${escapeHtml(ex.name || '附錄')}` : ''; + }).filter(Boolean).join(' '); + return `
+
+ ${escapeHtml(f.formZh || f.form || '')} + ${escapeHtml(f.form || '')} + ${escapeHtml(f.filedDate || '')}${f.reportDate && f.reportDate !== f.filedDate ? ` · 報告日 ${escapeHtml(f.reportDate)}` : ''} + ${f.description ? `

${escapeHtml(String(f.description).slice(0, 160))}

` : ''} + ${f.excerpt ? `

${escapeHtml(f.excerpt.slice(0, 280))}…

` : ''} +
+ +
`; + }).join('') : '
尚無封存申報。按「抓取並封存」會從 SEC 下載 10-K、10-Q、8-K、DEF 14A 等重要表格。
'; + + const earnRows = earnings.length ? earnings.map(e => ` +
+
${escapeHtml(e.titleZh || e.title || '')}${escapeHtml(e.eventDate || '')} ${escapeHtml(e.timeLabel || '')} · ${escapeHtml(e.source || '')}
+

${escapeHtml((e.note || '').slice(0, 200))}

+ +
`).join('') : '
尚無財報日曆或 8-K 財報事件;同步後會一併寫入。
'; + + body.innerHTML = ` +
+

SEC 重要申報(本機)

+
${filingRows}
+
+
+

財報日與法說相關

+

法說逐字稿多由公司 IR 網站發布,免費來源不一定有全文;此處會存財報日、8-K 財報公告與本機副本連結。

+
${earnRows}
+
`; +} + +async function loadSecArchive(symbol, sync) { + const body = $('#secArchiveBody'); + const status = $('#secArchiveStatus'); + if (!body) return; + if (sync) { + body.innerHTML = '
正在從 SEC 抓取並寫入本機,可能需要一~兩分鐘…
'; + if (status) status.textContent = '同步中…'; + } + try { + const data = sync + ? await api(`/api/sec-archive/${encodeURIComponent(symbol)}/sync?fresh=1`, { method: 'POST' }) + : await api(`/api/sec-archive/${encodeURIComponent(symbol)}`); + renderSecArchiveBody({ ...data, symbol }); + } catch (e) { + body.innerHTML = `
封存資料載入失敗:${escapeHtml((e.data && e.data.message) || e.message || '')}
`; + if (status) status.textContent = '失敗'; + } +} + +function bindSecArchivePanel(symbol) { + $('#secArchiveSync')?.addEventListener('click', () => loadSecArchive(symbol, true)); + $('#secArchiveRefresh')?.addEventListener('click', () => loadSecArchive(symbol, false)); + loadSecArchive(symbol, false); +} + +function intelResourceLinksHtml(links) { + const list = (links || []).filter(l => l?.url && !/google\.com\/search/i.test(l.url)); + if (!list.length) return ''; + return ``; +} + +function chainEntityChip(entity) { + const item = (entity && typeof entity === 'object') + ? entity + : { name: String(entity || ''), symbol: null }; + const name = item.name || item.symbol || '—'; + const sym = item.symbol || (/^[A-Z0-9.\-]{1,12}$/i.test(name) ? name.toUpperCase() : null); + const title = name.length > 14 ? ` title="${escapeHtml(name)}"` : ''; + if (sym && !/^原物料|終端|通路|待查|OEM/i.test(name)) { + return ``; + } + return `${escapeHtml(name)}`; +} + +function chainColHtml(items, detail) { + if (detail?.length) { + return detail.map(g => ` +
+ ${escapeHtml(g.label || '環節')} +
${(g.entities || []).map(e => chainEntityChip(e)).join('') || ''}
+ ${g.note ? `${escapeHtml(g.note)}` : ''} +
`).join(''); + } + return `
${(items || []).length ? items.map(x => chainEntityChip(x)).join('') : '待查證'}
`; +} + +function bindChainEntityClicks(box) { + $$('.chain-chips [data-peer], .peer-chips [data-peer]', box).forEach(btn => { + btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer)); + }); +} + +function decodeHtmlEntities(s) { + let t = String(s ?? ''); + if (!t) return ''; + t = t.replace(/&#x([0-9a-f]+);/gi, (_, hex) => { + const cp = parseInt(hex, 16); + return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : ''; + }); + t = t.replace(/&#(\d+);/g, (_, dec) => { + const cp = Number(dec); + return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : ''; + }); + for (const [ent, ch] of [['<', '<'], ['>', '>'], ['&', '&'], ['"', '"'], [''', "'"], [' ', ' ']]) { + if (t.includes(ent)) t = t.split(ent).join(ch); + } + return t; +} + +function cleanNewsDisplay(s) { + return decodeHtmlEntities(s).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function newsLooksLikeGarbage(s) { + const t = String(s || ''); + return /<|>|href\s*=|news\.google\.com\/rss/i.test(t) || /^https?:\/\//i.test(t); +} + +function newsSummaryText(n) { + const raw = cleanNewsDisplay(n.descriptionZh || n.description || ''); + if (!raw || newsLooksLikeGarbage(raw) || raw === cleanNewsDisplay(n.titleZh || n.title)) return ''; + return raw.slice(0, 220); +} + +function newsDisplayTitle(n) { + return cleanNewsDisplay(n.titleZh || n.title || '(無標題)'); +} + +function newsDisplayPublisher(n) { + const pub = cleanNewsDisplay(n.publisher || ''); + if (pub && !newsLooksLikeGarbage(pub)) return pub; + const src = cleanNewsDisplay(n.source || ''); + return src && !newsLooksLikeGarbage(src) ? src : '新聞'; +} + +function newsCardsHtml(list) { + if (!list.length) return ''; + return list.map(n => ` + + ${escapeHtml(newsDisplayTitle(n))} + ${n.title && cleanNewsDisplay(n.title) !== newsDisplayTitle(n) ? `${escapeHtml(cleanNewsDisplay(n.title))}` : ''} + ${escapeHtml(newsDisplayPublisher(n))}${n.created ? ` · ${escapeHtml(n.created)}` : ''} + ${newsSummaryText(n) ? `

${escapeHtml(newsSummaryText(n))}

` : ''} +
`).join(''); +} + +function newsPanelHtml(list) { + return list.length + ? `
${newsCardsHtml(list)}
` + : '
此區尚無新聞。
'; +} + +function bindNewsTabs(box) { + const tabs = $$('.news-tab', box); + const panels = $$('.news-panel', box); + tabs.forEach(btn => btn.addEventListener('click', () => { + tabs.forEach(t => { + const on = t === btn; + t.classList.toggle('active', on); + t.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + panels.forEach(p => p.classList.toggle('hidden', p.dataset.panel !== btn.dataset.tab)); + })); +} + +function impactCls(impact) { + if (impact === 'positive') return 'good'; + if (impact === 'negative') return 'bad'; + return 'warn'; +} + +const _intelSyncInflight = new Set(); + +function intelSyncStatusText(intel) { + if (intel?.syncSkipReason) return intel.syncSkipReason; + if (intel?.nextRefreshAfter) return `下次更新:${intel.nextRefreshAfter}(${intel.nextPublicLabel || '下次財報'})`; + if (intel?.enrichedAt) { + return `已更新 ${new Date(intel.enrichedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`; + } + return '首次進入將自動抓取'; +} + +async function runIntelSync(symbol, profile, force) { + const st = $('#intelSyncStatus'); + if (_intelSyncInflight.has(symbol)) return; + _intelSyncInflight.add(symbol); + if (st) { + st.textContent = force + ? `AI 正在更新 ${symbol} 的供應商與「誰買他們產品」客戶名單…` + : '正在抓取管理層、新聞、產業鏈(Yahoo/SEC/Google 新聞)…'; + } + try { + const qs = force ? '?fresh=1' : ''; + const res = await api(`/api/company-intel/${encodeURIComponent(symbol)}/sync${qs}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: !!force }), + }); + if (res.skipped && res.skipReason) { + if (st) st.textContent = res.skipReason; + if (res.intel) await renderCompanyIntel(symbol, profile, false, res.intel); + return; + } + if (st) st.textContent = '同步完成,更新畫面…'; + await renderCompanyIntel(symbol, profile, false, res.intel || null); + } catch (e) { + if (st) st.textContent = (e.data && e.data.message) || e.message || '同步失敗'; + } finally { + _intelSyncInflight.delete(symbol); + } +} + +function chainNeedsEnrich(intel) { + const d = intel?.industryChain || {}; + const groups = [...(d.upstreamDetail || []), ...(d.downstreamDetail || [])]; + const clickable = groups.flatMap(g => g.entities || []).filter(e => (e?.symbol || null)); + const onlyPlaceholder = groups.length > 0 && groups.every(g => + (g.entities || []).every(e => /待查證|原物料|終端|通路|OEM/i.test(String(e?.name || e))), + ); + return clickable.length < 2 && (onlyPlaceholder || !groups.length); +} + +function ensureIntelAutoSync(symbol, profile, intel) { + const missingOfficers = !(intel?.management?.officers || []).length; + const missingNews = !(intel?.newsTw || []).length && !(intel?.newsGlobal || []).length; + const weakChain = chainNeedsEnrich(intel); + if (!intel?.needsSync && !missingOfficers && !missingNews && !weakChain) return; + runIntelSync(symbol, profile, missingOfficers || missingNews || weakChain); +} + +function bindIntelCustomPanel(symbol) { + const saveBtn = $('#intelCustomSave'); + const loadBtn = $('#intelCustomLoad'); + const ta = $('#intelCustomJson'); + if (!saveBtn || !ta) return; + if (!ta.value.trim()) ta.value = INTEL_CUSTOM_SAMPLE; + loadBtn?.addEventListener('click', async () => { + try { + const d = await api(`/api/company-intel/${encodeURIComponent(symbol)}/custom`); + ta.value = d.data ? JSON.stringify(d.data, null, 2) : INTEL_CUSTOM_SAMPLE; + $('#intelCustomStatus').textContent = d.data ? '已載入本機資料' : '尚無本機資料'; + } catch (e) { + $('#intelCustomStatus').textContent = '載入失敗'; + } + }); + saveBtn.addEventListener('click', async () => { + const status = $('#intelCustomStatus'); + try { + const body = JSON.parse(ta.value); + await api(`/api/company-intel/${encodeURIComponent(symbol)}/custom`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + status.textContent = '已存入本機資料庫,正在更新畫面…'; + await renderCompanyIntel(symbol, null, true); + } catch (e) { + status.textContent = (e instanceof SyntaxError) ? 'JSON 格式錯誤' : ((e.data && e.data.message) || e.message || '儲存失敗'); + } + }); +} + +async function renderCompanyIntel(symbol, profile, fresh, prefetched) { const box = renderCompanyIntelSkeleton(); try { - const intel = await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`); + const intel = prefetched && !fresh + ? prefetched + : await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`); const chain = intel.industryChain || {}; const officers = intel.management?.officers || []; const insiders = intel.insiders || []; - const news = intel.news || []; + const newsTw = intel.newsTw || (intel.news || []).filter(n => n.region === 'tw'); + const newsGlobal = intel.newsGlobal || (intel.news || []).filter(n => n.region === 'global'); + const mgmtBrief = intel.managementBrief || []; const acquiredCount = insiders.filter(t => t.signal === 'acquire').length; const disposedCount = insiders.filter(t => t.signal === 'dispose').length; + const mgmtSource = intel.management?.source || '公開資料'; + const { sectorZh, industryZh } = sectorIndustryZh(profile, intel); + const profileDesc = intel.profileZh?.description || intel.management?.longBusinessSummary || ''; + const enrichNote = intel.aiEnriched ? ' · AI 已整理' : ''; + const syncNote = intelSyncStatusText(intel); + const healthNotes = (intel.dataHealth?.notes || []).map(n => `
  • ${escapeHtml(n)}
  • `).join(''); box.innerHTML = ` -
    -

    產業上下游

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

    公司簡介

    ${escapeHtml(intel.profileZh?.businessModel || industryZh)}
    +

    ${escapeHtml(profileDesc)}

    +
    ` : ''} +
    +

    產業上下游 · ${escapeHtml(symbol)}

    ${escapeHtml(industryZh)} · 強制更新會請 AI 重查供應商與下游客戶
    +
    +
    上游 · 供應商${chainColHtml(chain.upstream, chain.upstreamDetail)}
    +
    下游 · 誰買他們的產品${chainColHtml(chain.downstream, chain.downstreamDetail)}
    - - ${(chain.peers || []).length ? `
    ${chain.peers.map(s => ``).join('')}
    ` : ''} + ${chain.tenKExcerpt ? `

    10-K 摘要 ${escapeHtml(chain.tenKExcerpt)}${chain.tenKExcerpt.length >= 400 ? '…' : ''}

    ` : ''} + ${intelResourceLinksHtml(intel.resources)}
    -

    經營管理層

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

    經營層動態

    人事、指引、併購、治理相關
    +
    ${mgmtBrief.length ? mgmtBrief.map(m => ` +
    +
    ${escapeHtml(m.headline || '')}${escapeHtml(m.date || '')} · ${escapeHtml(m.source || '')}
    +

    ${escapeHtml(m.summary || '')}

    + ${m.url ? `原文` : ''} +
    `).join('') : '
    進入本頁會自動從新聞篩選管理層相關消息。
    '}
    -

    內部人 Form 4

    A/D 代表 SEC 交易取得/處分代碼,需留意獎酬與選擇權情境
    +

    內部人申報(SEC Form 4)

    申報人買賣自家股票;A=取得、D=處分
    -
    ${acquiredCount}近期偏取得
    -
    ${disposedCount}近期偏處分
    +
    ${acquiredCount}近期偏買進
    +
    ${disposedCount}近期偏賣出
    + }).join('') : '
    近期沒有抓到 Form 4 申報。
    '}
    -
    -

    新聞

    最近與公司或相關代號有關的新聞
    - +
    +

    相關新聞

    台灣媒體與國際第一手來源分開顯示
    +
    + + +
    +
    ${newsPanelHtml(newsTw)}
    + +
    +
    +

    重要申報與財報/法說

    10-K、10-Q、8-K、DEF 14A 等會下載到本機,避免日後連結失效
    +
    載入封存清單…
    +
    + + + +
    +
    +
    +

    經營管理層

    自動抓取 · 來源:${escapeHtml(mgmtSource)}
    +

    首次進入本頁會從 Yahoo/SEC 10-K 自動取得名單與職稱(中文對照);下次財報公開日前不會重複抓取。

    +
    ${officers.length ? officers.map(o => ` +
    ${escapeHtml(o.name)}${escapeHtml(o.titleDisplay || o.titleZh || o.title || '')}${o.totalPay != null ? '總薪酬 ' + fmtMoney(o.totalPay) : (o.source ? escapeHtml(o.source) : '')}
    `).join('') : '
    正在抓取管理層名單…若久未出現請按「強制更新」。
    '} + ${intelResourceLinksHtml(intel.management?.resources || intel.resources)}
    `; - $$('.peer-chips button', box).forEach(btn => btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer))); + bindChainEntityClicks(box); + bindNewsTabs(box); + $('#intelSyncBtn')?.addEventListener('click', () => runIntelSync(symbol, profile, true)); + bindSecArchivePanel(symbol); + ensureIntelAutoSync(symbol, profile, intel); } catch (e) { box.innerHTML = `
    無法整理公司研究資訊:${escapeHtml((e.data && e.data.message) || e.message || '')}
    `; } diff --git a/index.html b/index.html index 01fc931..85abeff 100644 --- a/index.html +++ b/index.html @@ -180,6 +180,8 @@ a{color:var(--blue);text-decoration:none} #tooltip .tip-row{margin-bottom:7px} #tooltip .tip-row:last-child{margin-bottom:0} #tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px} +#tooltip .tip-formula{margin:6px 0 0;padding:8px 10px;background:rgba(0,0,0,.35);border-radius:8px;font-size:.68rem;line-height:1.45;white-space:pre-wrap;word-break:break-word} +#tooltip .tip-caveat{color:#ffb4a8} #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} @@ -262,6 +264,73 @@ a{color:var(--blue);text-decoration:none} .ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px} .ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem} +/* ── 板塊熱力圖/輪動/資金 ── */ +#group-sectors.section{margin-top:24px} +#group-sectors .sector-panel{margin:0;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px 20px;box-shadow:var(--soft-shadow);max-width:100%;overflow:hidden} +.sector-panel-head{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px} +.sector-panel-head h2{font-size:1.05rem;font-weight:800;margin:0} +.sector-panel-head p{margin:6px 0 0;font-size:.78rem;color:var(--text2);line-height:1.5;max-width:100%} +.sector-rotation-banner{background:#f6f8f4;border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:16px} +.sector-rotation-banner b{display:block;font-size:.92rem;margin-bottom:6px} +.sector-rotation-banner span{font-size:.78rem;color:var(--text2);line-height:1.55} +.sector-quad-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:12px} +.sector-quad{padding:10px;border-radius:10px;border:1px solid var(--border);background:#fff;min-width:0;overflow:hidden} +.sector-quad em{display:block;font-size:.68rem;color:var(--text2);font-style:normal;margin-bottom:6px} +.sector-quad span{font-size:.72rem;line-height:1.4;word-break:break-word;overflow-wrap:anywhere} +.sector-heatmap-wrap{margin-bottom:16px;max-width:100%} +.sector-heatmap{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px} +.sector-heat-cell{border-radius:10px;border:1px solid rgba(0,0,0,.06);padding:10px;min-height:88px;display:flex;flex-direction:column;justify-content:space-between} +.sector-heat-cell b{font-size:.8rem;line-height:1.25} +.sector-heat-cell small{font-size:.65rem;color:var(--text2)} +.sector-heat-val{font-size:1rem;font-weight:800;margin:6px 0} +.sector-heat-row{display:flex;gap:6px;flex-wrap:wrap;font-size:.62rem;color:var(--text2)} +.sector-heat-row span{background:rgba(255,255,255,.55);padding:2px 5px;border-radius:4px} +.sector-flow-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px} +.sector-flow-col h4{margin:0 0 10px;font-size:.82rem} +.sector-flow-bar{display:grid;gap:6px} +.sector-flow-item{display:grid;grid-template-columns:72px 1fr 52px;gap:8px;align-items:center;font-size:.72rem} +.sector-flow-item .bar{height:8px;border-radius:4px;background:rgba(0,0,0,.06);overflow:hidden} +.sector-flow-item .bar i{display:block;height:100%;border-radius:4px} +.sector-inst-wrap{overflow-x:auto;margin-bottom:8px;-webkit-overflow-scrolling:touch} +.sector-inst-table{width:100%;min-width:320px;border-collapse:collapse;font-size:.74rem;table-layout:fixed} +.sector-inst-table th,.sector-inst-table td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sector-inst-table th{color:var(--text2);font-weight:600} +.sector-inst-table td:first-child{white-space:normal} +.sector-inst-note{font-size:.7rem;color:var(--text2);line-height:1.5;margin-top:10px} +.sector-holdings-block{margin-top:18px;padding-top:16px;border-top:1px solid var(--border)} +.sector-holdings-block>h4{font-size:.82rem;margin:0 0 8px} +.sector-holdings-lead{font-size:.76rem;color:var(--text2);line-height:1.55;margin:0 0 12px} +.sector-holdings-top{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px} +.sector-hold-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:20px;border:1px solid var(--border);background:#f9faf7;font-size:.72rem} +.sector-hold-chip button{background:none;border:none;padding:0;color:var(--blue);font-weight:700;cursor:pointer;font-size:.72rem} +.sector-hold-chip small{color:var(--text2)} +.sector-holdings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} +.sector-hold-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:#fafbf8;min-width:0} +.sector-hold-card h5{margin:0 0 4px;font-size:.8rem} +.sector-hold-card .hold-reason{font-size:.68rem;color:var(--text2);line-height:1.45;margin:0 0 10px} +.sector-hold-list{list-style:none;margin:0;padding:0;display:grid;gap:6px} +.sector-hold-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;font-size:.72rem} +.sector-hold-list button{background:none;border:none;padding:0;text-align:left;color:var(--blue);font-weight:600;cursor:pointer;font-size:.72rem} +.sector-hold-list span{color:var(--text2);font-variant-numeric:tabular-nums} +.sector-hold-faq{margin-top:12px;font-size:.72rem;color:var(--text2);line-height:1.55} +.sector-hold-faq summary{cursor:pointer;color:var(--text);font-weight:600} +@media(max-width:1100px){ + .macro-hero{grid-template-columns:1fr} + .signal-bar{grid-template-columns:1fr} + .signal-bar .signal-regime{flex-wrap:wrap} +} +@media(max-width:900px){ + #group-sectors.section{margin-left:16px;margin-right:16px} + .sector-quad-grid{grid-template-columns:1fr 1fr} + .sector-flow-grid{grid-template-columns:1fr} + .sector-heatmap{grid-template-columns:repeat(3,minmax(0,1fr))} +} +@media(max-width:600px){ + .sector-heatmap{grid-template-columns:repeat(2,minmax(0,1fr))} + .sector-quad-grid{grid-template-columns:1fr} + .sector-holdings-grid{grid-template-columns:1fr} +} + /* ── Responsive ── */ @media(max-width:900px){ .macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px} @@ -294,6 +363,7 @@ a{color:var(--blue);text-decoration:none}
    + @@ -510,6 +581,7 @@ function macroHeroHTML(data, scoreColor, signals){
    +
    @@ -538,7 +610,133 @@ function macroHeroHTML(data, scoreColor, signals){
    `; } -function render(data){ +function fmtPct(v,d=1){ + if(v==null||Number.isNaN(v)) return '—'; + return (v>=0?'+':'')+v.toFixed(d)+'%'; +} +function heatCellStyle(pct){ + if(pct==null) return 'background:#f0f1ee'; + const v=Math.max(-8,Math.min(8,pct)); + if(v>=0) return `background:rgba(31,157,102,${0.15+v/8*0.45})`; + return `background:rgba(216,79,69,${0.15+Math.abs(v)/8*0.45})`; +} +function sectorSectionHTML(sd){ + if(!sd||!sd.sectors) return ''; + const rows=(sd.sectors||[]).filter(s=>!s.error); + const rot=sd.rotation||{}; + const inst=sd.institutional||{}; + const quadLabels={leading:'領漲',weakening:'轉弱',improving:'改善',lagging:'落後'}; + const quadHtml=Object.keys(quadLabels).map(k=>{ + const list=(rot.byQuadrant&&rot.byQuadrant[k])||[]; + const names=list.map(e=>{ + const m=rows.find(r=>r.etf===e); + return m?m.nameZh:e; + }).join('、')||'—'; + return `
    ${quadLabels[k]}${names}
    `; + }).join(''); + const heatHtml=rows.map(s=>{ + const main=s.ret5d!=null?s.ret5d:s.ret1d; + return `
    +
    ${s.nameZh}${s.etf}
    +
    ${fmtPct(main)}
    +
    + 1日 ${fmtPct(s.ret1d)} + 20日 ${fmtPct(s.ret20d)} + RS ${fmtPct(s.rs20)} +
    +
    `; + }).join(''); + const maxFlow=Math.max(...rows.map(r=>Math.abs(r.flowScore||0)),0.01); + const flowBar=(list,title)=>{ + const items=(list||[]).map(r=>{ + const w=Math.min(100,Math.abs((r.flowScore||0)/maxFlow)*100); + const col=(r.flowScore||0)>=0?'var(--green)':'var(--red)'; + return `
    + ${r.nameZh} +
    + ${fmtPct(r.ret5d)} +
    `; + }).join(''); + return `

    ${title}

    ${items||''}
    `; + }; + const instRows=(inst.byAum||[]).slice(0,11).map(r=>` + ${r.nameZh} ${r.etf||''} + ${inst.aumProxy?(r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'):('$'+(r.aumB!=null?r.aumB.toFixed(1):'—')+'B')} + ${r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'} + `).join(''); + const instHead=inst.aumProxy?'動能占比相對權重':'ETF 規模占 11 板塊合計'; + const exp=sd.stockExposure||{}; + const topChips=(exp.topStocks||[]).slice(0,8).map(s=>`${s.name}`).join(''); + const packHtml=(exp.packs||[]).map(p=>`
    +
    ${p.nameZh} ${p.etf}
    +

    ${p.reason||''}

    + +
    `).join(''); + const holdingsBlock=(exp.packs&&exp.packs.length)?` +
    +

    機構資金落在哪些股票?

    +

    ${exp.howToRead||''}

    + ${topChips?`
    ${topChips}
    `:''} +
    ${packHtml}
    +
    ETF 持股 vs 13F:差在哪? +

    這裡(ETF 持股):看 XLK、SPY 等基金「裡面裝什麼」,反映被動指數與板塊 ETF 的結構性配置,更新約每月。

    +

    13F:美國管理超過 1 億美元機構每季申報的「股票部位」清單(含主動基金),約延遲 45 天,可在 SEC EDGAR 查 Berkshire、Bridgewater 等個別持倉。

    +

    近期流向:上方「資金流向偏強/偏弱」是價量推估,不是逐筆買賣;要追單一股票可再到「個股工具」看量能與新聞。

    +
    +

    ${exp.disclaimer||''}

    +
    `:''; + const cached=sd.cached?` · 快取${sd.cachedAt?new Date(sd.cachedAt).toLocaleString('zh-TW',{hour:'2-digit',minute:'2-digit'}):''}`:''; + return ` +
    +
    +
    +
    +

    板塊熱力圖與資金輪動

    +

    以 SPDR 11 大行業 ETF 對照 ${sd.benchmark||'SPY'}:熱力圖看漲跌、輪動看相對強度、流向看價量、下方可看 ETF 實際持股(機構多透過 ETF 間接持有)。${cached}

    +
    + +
    +
    + 目前輪動:${rot.regime||'—'}${rot.leader?` · 領先 ${rot.leader.nameZh}`:''}${rot.laggard?` · 落後 ${rot.laggard.nameZh}`:''} + ${rot.regimeNote||''} +
    ${quadHtml}
    +
    +

    板塊熱力圖 (格內主數字為 5 日漲跌,列為 1日/20日/相對大盤 RS)

    +
    ${heatHtml}
    +
    + ${flowBar(inst.flowLeaders,'近期資金流向偏強')} + ${flowBar(inst.flowLaggards,'近期資金流向偏弱')} +
    +

    板塊 ETF 規模${inst.aumProxy?'(流向動能占比)':'(總資產)'}

    +
    + + ${instHead} + ${instRows} +
    板塊
    +
    +

    ${inst.disclaimer||''}${inst.totalAumB!=null?` 合計約 $${inst.totalAumB.toFixed(0)}B。`:''}

    + ${holdingsBlock} +
    +
    `; +} + +function sectorFailHTML(reason){ + const msg=reason||'板塊資料暫時無法載入。請確認已用最新程式啟動伺服器(終端機執行 npm start),再按 Cmd+Shift+R 強制重新整理。'; + return ` +
    +
    +
    +
    +

    板塊熱力圖與資金輪動

    +

    ${msg}

    +
    + +
    +
    +
    `; +} + +function render(data, sectorData, sectorFailed){ const main=document.getElementById('view-macro'); const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow'); @@ -551,6 +749,8 @@ function render(data){ TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]}; let html = macroHeroHTML(data, scoreColor, signals) + guideHTML(); + if(sectorData) html += sectorSectionHTML(sectorData); + else if(sectorFailed) html += sectorFailHTML(); // 降級提示 if(data.degraded&&data.degraded.length){ @@ -559,6 +759,7 @@ function render(data){ // 各分組 const nav=[]; + if(sectorData||sectorFailed) nav.push(`板塊資金`); (data.groups||[]).forEach(g=>{ if(!g.cards||g.cards.length===0) return; nav.push(`${g.title}`); @@ -591,6 +792,10 @@ function render(data){ main.innerHTML=html; document.getElementById('navLinks').innerHTML=nav.join(''); + document.getElementById('sectorRefreshBtn')?.addEventListener('click',()=>loadSectors(true).then(sd=>{ + if(!window.__MACRO_DATA) return; + render(window.__MACRO_DATA, sd||null, !sd); + })); // 更新時間 const t=new Date(data.updatedAt||Date.now()); @@ -605,6 +810,12 @@ function render(data){ const el=document.getElementById(btn.dataset.scrollTarget); if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'}); })); + document.querySelectorAll('.stk-jump').forEach(btn=>btn.addEventListener('click',()=>{ + const sym=btn.dataset.sym; + if(!sym) return; + if(typeof window.setStockSymbol==='function') window.setStockSymbol(sym); + location.hash='#/stock'; + })); const sc=document.getElementById('scoreClick'); if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});} } @@ -921,18 +1132,33 @@ function bindChartHover(points,opts){ // ═══════════════════════════════════════════════════════════ // 載入資料 // ═══════════════════════════════════════════════════════════ +async function loadSectors(fresh){ + try{ + const r=await fetch('/api/sectors'+(fresh?'?fresh=1':'')); + const sd=await r.json(); + if(!r.ok) return null; + return sd; + }catch{ return null; } +} + async function load(fresh){ const main=document.getElementById('view-macro'); main.innerHTML=`
    正在抓取真實總經資料…
    `; try{ - const [res,evRes]=await Promise.all([ + const [res,evRes,sectorRes]=await Promise.all([ fetch('/api/macro'+(fresh?'?fresh=1':'')), fetch('/api/events').catch(()=>null), + fetch('/api/sectors'+(fresh?'?fresh=1':'')).catch(()=>null), ]); const data=await res.json(); if(!res.ok){ renderError(data); return; } if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} } - render(data); + let sectorData=null; + let sectorFailed=false; + if(sectorRes&§orRes.ok){ try{sectorData=await sectorRes.json();}catch{sectorFailed=true;} } + else if(sectorRes) sectorFailed=true; + window.__MACRO_DATA=data; + render(data,sectorData,sectorFailed); }catch(err){ renderError({message:'無法連線到伺服器。請確認伺服器已啟動(npm start)。',detail:String(err)}); } diff --git a/lib/ai-client.js b/lib/ai-client.js new file mode 100644 index 0000000..2a91998 --- /dev/null +++ b/lib/ai-client.js @@ -0,0 +1,86 @@ +// 伺服器端呼叫已設定的 AI Provider(OpenCode Go / Grok) +const AI_PROVIDERS = { + 'opencode-go': { + label: 'OpenCode Go', + endpoint: 'https://opencode.ai/zen/go/v1/chat/completions', + keyEnv: 'OPENCODE_GO_API_KEY', + modelEnv: 'OPENCODE_GO_MODEL', + mode: 'chat', + }, + grok: { + label: 'Grok', + endpoint: 'https://api.x.ai/v1/responses', + keyEnv: 'GROK_API_KEY', + modelEnv: 'GROK_MODEL', + mode: 'responses', + }, +}; + +function normalizeAIText(data, mode) { + if (mode === 'responses') { + if (data?.output_text) return data.output_text; + const chunks = []; + for (const item of data?.output || []) { + for (const c of item?.content || []) { + if (typeof c?.text === 'string') chunks.push(c.text); + else if (typeof c?.content === 'string') chunks.push(c.content); + } + } + return chunks.join('\n').trim(); + } + return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || ''; +} + +export function getActiveAIConfig() { + const providerId = String(process.env.AI_ACTIVE_PROVIDER || 'grok').trim(); + const provider = AI_PROVIDERS[providerId]; + if (!provider) return null; + const apiKey = String(process.env[provider.keyEnv] || '').trim(); + const model = String(process.env[provider.modelEnv] || '').trim(); + if (!apiKey) return null; + return { providerId, provider, apiKey, model }; +} + +export function extractJSONObject(text) { + const raw = String(text || '').trim(); + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]; + const candidate = fenced || raw; + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch { + return null; + } +} + +export async function callAI({ system, user, temperature = 0.15, timeoutMs = 90000 }) { + const cfg = getActiveAIConfig(); + if (!cfg) return { ok: false, error: 'no_ai_key', text: null }; + let { model, provider, apiKey, providerId } = cfg; + if (!model) model = 'grok-3-mini'; + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const body = provider.mode === 'responses' + ? { model, store: false, temperature, input: [{ role: 'system', content: system }, { role: 'user', content: user }] } + : { model, temperature, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }; + const r = await fetch(provider.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + return { ok: false, error: data?.error?.message || data?.message || `HTTP ${r.status}`, text: null, providerId }; + } + return { ok: true, text: normalizeAIText(data, provider.mode), providerId, model }; + } catch (e) { + return { ok: false, error: String(e?.message || e), text: null, providerId }; + } finally { + clearTimeout(timer); + } +} \ No newline at end of file diff --git a/lib/companyintel-ai.js b/lib/companyintel-ai.js new file mode 100644 index 0000000..14f1432 --- /dev/null +++ b/lib/companyintel-ai.js @@ -0,0 +1,312 @@ +// AI 整理公司研究為固定 JSON 結構(產業鏈、簡介、管理層動態、新聞摘要) +import { callAI, extractJSONObject } from './ai-client.js'; +import { getCompanyIntelEnriched, saveCompanyIntelEnriched } from './db.js'; +import { computeNextPublicRefresh, shouldRunIntelSync, intelRefreshPolicy } from './companyintel-refresh.js'; +import { localizeOfficer, sanitizeOfficers } from './companyintel-i18n.js'; +import { + mergeNewsIntoChain, finalizeIndustryChain, mergeEnrichedChain, layoutPeersIntoGrid, ensureDownstreamBuyers, +} from './companyintel-chain.js'; + +const ENRICH_SCHEMA = `{ + "profileZh": { + "description": "80-220字繁體中文公司簡介", + "businessModel": "一句話商業模式" + }, + "industryChain": { + "upstream": [{ "label": "環節名稱", "entities": ["供應商公司名或代號"], "note": "15字內;標 10-K、新聞、AI" }], + "downstream": [{ "label": "客戶類型", "entities": ["購買標的公司產品/服務的公司名或代號"], "note": "15字內;說明為何是客戶" }], + "peers": ["同業代號大寫;台股如2330.TW"] + }, + "managementBrief": [ + { "date": "YYYY-MM-DD", "headline": "標題", "summary": "2-3句繁中", "impact": "positive|neutral|negative", "source": "來源名" } + ], + "newsHighlights": [ + { "region": "tw|global", "titleZh": "繁中標題", "summaryZh": "一句摘要", "url": "原文連結", "publisher": "媒體" } + ] +}`; + +function heuristicChain(symbol, bundle, profile = {}) { + const hints = bundle.hints || {}; + const ext = bundle.profileExt || {}; + const industry = `${ext.industry || ''} ${ext.sector || profile.industry || ''}`.toLowerCase(); + const upstream = (hints.suppliers || []).slice(0, 12).map(s => ({ label: '供應商', entities: [s], note: '10-K' })); + const downstream = (hints.customers || []).slice(0, 12).map(s => ({ label: '購買方(10-K)', entities: [s], note: 'SEC 10-K' })); + const peers = [...new Set([...(ext.peers || []), ...(hints.competitors || [])])].filter(p => p !== symbol).slice(0, 10); + if (!upstream.length) { + if (/semiconductor|chip/i.test(industry)) { + upstream.push( + { label: 'EDA/IP', entities: ['Synopsys', 'Cadence'], note: '' }, + { label: '晶圓代工', entities: ['TSMC', 'Samsung'], note: '' }, + { label: '封裝測試', entities: ['ASE', 'Amkor'], note: '' }, + ); + } + } + if (!downstream.length && !/semiconductor|chip/i.test(industry)) { + /* 非半導體不填泛稱下游 */ + } + let chain = { + upstream: upstream.length ? upstream : [{ label: '上游供應', entities: ['原物料/設備/服務商'], note: '待查證' }], + downstream: downstream.length ? downstream : [{ label: '下游客戶', entities: ['待查證'], note: '按強制更新由 AI 整理' }], + peers, + }; + const news = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])]; + chain = mergeNewsIntoChain(chain, news, symbol); + return ensureDownstreamBuyers(finalizeIndustryChain(chain, symbol), symbol, profile); +} + +function fallbackManagementBrief(bundle) { + return (bundle.managementNewsRaw || []).slice(0, 6).map(n => ({ + date: n.created || null, + headline: n.titleZh || n.title || '', + summary: (n.descriptionZh || n.description || '').slice(0, 180), + impact: 'neutral', + source: n.publisher || n.source || '', + url: n.url || null, + })); +} + +function normalizeEnriched(parsed, symbol, bundle, profile) { + const chain = parsed?.industryChain || heuristicChain(symbol, bundle, profile); + const upDetail = (chain.upstream || []).some(g => g?.entities) + ? chain.upstream + : (chain.upstreamDetail || []); + const downDetail = (chain.downstream || []).some(g => g?.entities) + ? chain.downstream + : (chain.downstreamDetail || []); + const flatUpstream = upDetail.flatMap(u => (u.entities || []).map(e => String(typeof e === 'object' ? e.name : e))); + const flatDownstream = downDetail.flatMap(d => (d.entities || []).map(e => String(typeof e === 'object' ? e.name : e))); + const mgmt = (parsed?.managementBrief || []).length ? parsed.managementBrief : fallbackManagementBrief(bundle); + return { + profileZh: parsed?.profileZh || { + description: bundle.profileExt?.longBusinessSummary?.slice(0, 400) || bundle.hints?.excerpt?.slice(0, 400) || '', + businessModel: bundle.profileExt?.industry || '', + }, + industryChain: finalizeIndustryChain({ + upstream: flatUpstream.slice(0, 12), + downstream: flatDownstream.slice(0, 12), + peers: (chain.peers || []).map(s => String(s).toUpperCase()).filter(Boolean).slice(0, 12), + upstreamFlat: flatUpstream.slice(0, 12), + downstreamFlat: flatDownstream.slice(0, 12), + upstreamDetail: upDetail, + downstreamDetail: downDetail, + }, symbol), + managementBrief: mgmt.slice(0, 8).map(m => ({ + date: m.date || null, + headline: m.headline || '', + summary: m.summary || '', + impact: m.impact || 'neutral', + source: m.source || '', + url: m.url || null, + })), + newsHighlights: (parsed?.newsHighlights || []).slice(0, 16), + enrichedAt: new Date().toISOString(), + aiUsed: !!parsed?._aiUsed, + provider: parsed?._provider || null, + }; +} + +function buildForceEnrichPrompt(symbol, profile = {}) { + const name = profile.name || profile.companyName || symbol; + const industry = profile.industry || profile.sector || ''; + return [ + `【強制更新】標的:${symbol}(${name})${industry ? `,產業:${industry}` : ''}`, + '請依固定 JSON 結構輸出(不要 midstream;頁面只顯示上游、下游兩欄)。', + `upstream:2~5 組供應商;每組 entities 為 2~6 個具體公司名或股票代號(美股 1-5 字大寫;台股 2330.TW)。`, + `downstream:2~4 組「誰購買 ${symbol} 的產品或服務」;必須具名客戶(公司名或代號),禁止只寫終端客戶、企業客戶、通路等泛稱;優先採用 10-K customers 與新聞中的買方。`, + /NVDA|AMD/i.test(symbol) + ? 'GPU 範例下游:DELL、HPE、SMCI(AI 伺服器 OEM,採購 GPU 組裝再銷售)、MSFT、AMZN、GOOGL、META(雲端部署);同業 AMD 放 peers 勿放 downstream。' + : null, + 'peers:3~8 個同業代號。', + '每個 downstream 的 note 用 15 字內說明客戶與標的公司的關係(如雲端採購 GPU、OEM 採購晶片)。', + '資料僅能來自提供的原始摘要;沒有依據則該組 entities 填「待查證」。', + ].join('\n'); +} + +export async function enrichWithAI(symbol, bundle, profile = {}, { force = false } = {}) { + const compact = { + symbol, + sector: bundle.profileExt?.sector, + industry: bundle.profileExt?.industry, + summary: bundle.profileExt?.longBusinessSummary?.slice(0, 1200), + tenK: { + excerpt: bundle.hints?.excerpt?.slice(0, 1500), + customers: bundle.hints?.customers, + suppliers: bundle.hints?.suppliers, + competitors: bundle.hints?.competitors, + }, + headlines8k: (bundle.headlines8k || []).slice(0, 6), + managementNews: (bundle.managementNewsRaw || []).slice(0, 8).map(n => ({ + title: n.title, publisher: n.publisher, created: n.created, url: n.url, + })), + newsTw: (bundle.newsTw || []).slice(0, 10).map(n => ({ + title: n.title, publisher: n.publisher, url: n.url, + summary: (n.description || n.descriptionZh || '').slice(0, 200), + })), + newsGlobal: (bundle.newsGlobal || []).slice(0, 10).map(n => ({ + title: n.title, publisher: n.publisher, url: n.url, + summary: (n.description || n.descriptionZh || '').slice(0, 200), + })), + }; + + const system = force + ? [ + '你是股票研究資料編輯。只輸出一段合法 JSON,不要 markdown,不要解釋。', + '產業鏈僅 upstream(供應商)與 downstream(購買標的公司產品/服務的客戶),不要 midstream。', + 'downstream 是本次重點:具名買方公司或代號,結構穩定以利網頁兩欄顯示。', + 'managementBrief 3-6 則;newsHighlights 6-10 則,region 為 tw 或 global。', + ].join('\n') + : [ + '你是股票研究資料編輯。只輸出一段合法 JSON,不要 markdown,不要解釋。', + '資料來自公開來源摘要,不可捏造未出現的公司名;不確定處用「待查證」。', + 'upstream=供應商;downstream=購買標的公司產品/服務的客戶;不可只寫泛稱。', + '不要輸出 midstream。entities 盡量用可交易代號;note 標 10-K、新聞、AI。', + 'managementBrief 只收經營層、治理、策略、併購、指引相關 3-6 則。', + 'newsHighlights 從新聞挑選 6-10 則,region 為 tw 或 global。', + ].join('\n'); + + const task = force ? buildForceEnrichPrompt(symbol, { ...profile, ...bundle.profileExt }) : `股票代號 ${symbol}。請整理產業鏈與新聞。`; + const user = `${task}\n\nJSON 結構:\n${ENRICH_SCHEMA}\n\n原始資料:\n${JSON.stringify(compact, null, 2)}`; + + const ai = await callAI({ system, user, temperature: 0.1 }); + if (!ai.ok) { + return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: ai.error }; + } + const parsed = extractJSONObject(ai.text); + if (!parsed) { + return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: 'json_parse_failed' }; + } + parsed._aiUsed = true; + parsed._provider = ai.providerId; + return { data: normalizeEnriched(parsed, symbol, bundle, profile), aiError: null }; +} + +export async function syncCompanyIntelEnriched(symbol, profile = {}, { force = false, useAI = true, management = null } = {}) { + symbol = String(symbol || '').trim().toUpperCase(); + const existing = getCompanyIntelEnriched(symbol); + const gate = shouldRunIntelSync(existing, { force }); + if (!gate.run) { + return { + symbol, + skipped: true, + enriched: existing?.data, + sources: existing?.sources || [], + skipReason: gate.skipReason, + nextRefreshAfter: gate.nextRefreshAfter, + }; + } + + const { gatherIntelSources } = await import('./companyintel-sources.js'); + const bundle = await gatherIntelSources(symbol, profile); + const sources = ['Yahoo', 'SEC 10-K', 'Google 新聞 TW', 'Google 新聞 EN', 'Nasdaq', 'Yahoo Finance']; + + let enriched; + let aiError = null; + if (useAI) { + const r = await enrichWithAI(symbol, bundle, profile, { force }); + enriched = r.data; + aiError = r.aiError; + if (aiError) sources.push(`AI 略過(${aiError})`); + else sources.push(`AI ${enriched.provider || 'active'}`); + } else { + enriched = normalizeEnriched(null, symbol, bundle, profile); + } + + const pub = await computeNextPublicRefresh(symbol); + enriched.rawBundleAt = bundle.gatheredAt; + enriched.sources = sources; + enriched.enrichedAt = new Date().toISOString(); + enriched.lastSyncAt = Date.now(); + enriched.chainLayout = 'upstream_downstream_v2'; + if (force) enriched.forceRefreshAt = enriched.enrichedAt; + enriched.nextRefreshAfter = pub.nextRefreshAfter; + enriched.nextPublicLabel = pub.nextPublicLabel; + enriched.nextPublicDate = pub.nextPublicDate; + if (management?.officers?.length) { + enriched.officers = management.officers; + enriched.managementSource = management.source || null; + } + const newsAll = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])]; + enriched.industryChain = ensureDownstreamBuyers( + layoutPeersIntoGrid( + mergeNewsIntoChain( + finalizeIndustryChain(enriched.industryChain || {}, symbol), + newsAll, + symbol, + ), + symbol, + ), + symbol, + profile, + ); + saveCompanyIntelEnriched(symbol, enriched, sources); + return { + symbol, skipped: false, enriched, bundle, sources, aiError, + nextRefreshAfter: pub.nextRefreshAfter, + nextPublicLabel: pub.nextPublicLabel, + }; +} + +export function applyEnrichedToIntel(intel, enriched) { + if (!enriched) return intel; + const chain = enriched.industryChain || {}; + const newsTw = (intel.newsTw || []).length ? intel.newsTw : (intel.news || []).filter(n => n.region === 'tw'); + const newsGlobal = (intel.newsGlobal || []).length ? intel.newsGlobal : (intel.news || []).filter(n => n.region === 'global'); + + const highlights = enriched.newsHighlights || []; + const hlTw = highlights.filter(h => h.region === 'tw').map(h => ({ + title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh, + url: h.url, publisher: h.publisher, region: 'tw', source: 'AI 精選', + })); + const hlGl = highlights.filter(h => h.region === 'global').map(h => ({ + title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh, + url: h.url, publisher: h.publisher, region: 'global', source: 'AI 精選', + })); + + return { + ...intel, + profileZh: enriched.profileZh || intel.profileZh, + industryChain: ensureDownstreamBuyers( + mergeNewsIntoChain( + mergeEnrichedChain(intel.industryChain, enriched.industryChain, intel.symbol), + [...newsTw, ...newsGlobal], + intel.symbol, + ), + intel.symbol, + intel.profile || {}, + ), + managementBrief: enriched.managementBrief || [], + management: (() => { + const off = sanitizeOfficers(enriched.officers); + if (!off.length) return intel.management; + return { + ...(intel.management || {}), + officers: off.map(localizeOfficer), + source: enriched.managementSource || intel.management?.source, + }; + })(), + newsTw: [...hlTw, ...newsTw].slice(0, 14), + newsGlobal: [...hlGl, ...newsGlobal].slice(0, 14), + news: [...newsTw, ...newsGlobal].slice(0, 20), + enrichedAt: enriched.enrichedAt || (enriched.lastSyncAt ? new Date(enriched.lastSyncAt).toISOString() : null), + enrichSources: enriched.sources || [], + aiEnriched: !!enriched.aiUsed, + chainLayout: enriched.chainLayout || 'upstream_downstream_v2', + nextRefreshAfter: enriched.nextRefreshAfter || null, + nextPublicLabel: enriched.nextPublicLabel || null, + needsSync: false, + }; +} + +export function attachIntelSyncStatus(intel, symbol) { + const row = getCompanyIntelEnriched(symbol); + const gate = intelRefreshPolicy(row); + return { + ...intel, + needsSync: gate.needsSync, + nextRefreshAfter: gate.nextRefreshAfter || intel.nextRefreshAfter, + nextPublicLabel: gate.nextPublicLabel || intel.nextPublicLabel, + syncSkipReason: gate.skipReason, + lastSyncAt: gate.lastSyncAt || intel.enrichedAt, + }; +} + diff --git a/lib/companyintel-chain.js b/lib/companyintel-chain.js new file mode 100644 index 0000000..5bcb759 --- /dev/null +++ b/lib/companyintel-chain.js @@ -0,0 +1,463 @@ +// 產業鏈:新聞萃取、代號解析、實體可點擊結構 +const UP_KW = /供應商|供應|上游|代工|材料|零件|設備|晶圓|封裝|HBM|EDA|IP|vendor|supplier|supply|manufactur|foundry|TSMC/i; +const DOWN_KW = /客戶|下游|訂單|採購|部署|採用|合作|需求|customer|deploy|adopt|partner|cloud|data\s*center|hyperscale|server|伺服器|OEM|ODM|rack/i; +const OEM_BUYER_CTX = /server|伺服器|OEM|ODM|rack|AI\s*server|GPU\s*server|AI\s*infrastructure|資料中心/i; +const GPU_NEWS_CTX = /NVIDIA|NVDA|輝達|英偉達|GPU|Blackwell|H100|B200|accelerator/i; +const DOWNSTREAM_BUYER_SYMS = new Set([ + 'DELL', 'HPE', 'HPQ', 'SMCI', 'CSCO', 'MSFT', 'AMZN', 'GOOGL', 'META', 'ORCL', 'AAPL', 'TSLA', + '2317.TW', '2382.TW', 'LENOVO', +]); + +/** 公司名/中文簡稱 → 可切換代號 */ +const NAME_ALIASES = { + 台積電: 'TSM', 台积电: 'TSM', TSMC: 'TSM', 'Taiwan Semiconductor': 'TSM', + 輝達: 'NVDA', 英偉達: 'NVDA', NVIDIA: 'NVDA', + 超微: 'AMD', AMD: 'AMD', + 高通: 'QCOM', Qualcomm: 'QCOM', + 博通: 'AVGO', Broadcom: 'AVGO', + 聯發科: '2454.TW', MediaTek: '2454.TW', + 日月光: '3711.TW', ASE: '3711.TW', + 鴻海: '2317.TW', Foxconn: '2317.TW', 富士康: '2317.TW', + 廣達: '2382.TW', Quanta: '2382.TW', + 聯電: 'UMC', 'United Microelectronics': 'UMC', + 台塑: '1301.TW', 台塑化: '6505.TW', 中石化: '6505.TW', + 中油: '6505.TW', + 微軟: 'MSFT', Microsoft: 'MSFT', + 谷歌: 'GOOGL', Google: 'GOOGL', Alphabet: 'GOOGL', + 亞馬遜: 'AMZN', Amazon: 'AMZN', + 蘋果: 'AAPL', Apple: 'AAPL', + Meta: 'META', 臉書: 'META', Facebook: 'META', + 特斯拉: 'TSLA', Tesla: 'TSLA', + Synopsys: 'SNPS', Cadence: 'CDNS', + ASML: 'ASML', 'Applied Materials': 'AMAT', Lam: 'LRCX', KLA: 'KLAC', + 美光: 'MU', Micron: 'MU', + 三星: '005930.KS', Samsung: '005930.KS', + 英特爾: 'INTC', Intel: 'INTC', + 甲骨文: 'ORCL', Oracle: 'ORCL', + 思科: 'CSCO', Cisco: 'CSCO', + 戴爾: 'DELL', Dell: 'DELL', + 惠普: 'HPE', HP: 'HPQ', + 'Hewlett Packard Enterprise': 'HPE', 'Hewlett-Packard Enterprise': 'HPE', + 超微電腦: 'SMCI', 'Super Micro': 'SMCI', Supermicro: 'SMCI', + 'Dell Technologies': 'DELL', + 亞馬遜雲: 'AMZN', AWS: 'AMZN', + 微軟Azure: 'MSFT', Azure: 'MSFT', +}; + +function isUsTicker(s) { + return /^[A-Z]{1,5}$/.test(s); +} + +function isTwTicker(s) { + return /^\d{4}(\.TW)?$/i.test(s); +} + +const TICKER_BLOCKLIST = new Set([ + 'AI', 'IT', 'US', 'EU', 'UK', 'CEO', 'CFO', 'COO', 'GPU', 'CPU', 'CSP', 'API', 'EPS', 'SEC', 'IPO', + 'ETF', 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'TWD', 'FY', 'QOQ', 'YOY', 'AND', 'THE', 'FOR', 'INC', +]); + +export function isTradableSymbol(sym) { + const s = String(sym || '').toUpperCase().trim(); + if (!s || TICKER_BLOCKLIST.has(s)) return false; + if (isUsTicker(s)) return true; + if (isTwTicker(s)) return true; + if (/^\d{6}\.KS$/i.test(s)) return true; + return false; +} + +export function resolveEntitySymbol(raw, focalSymbol = '') { + const text = String(raw || '').trim(); + if (!text || text === '待查證' || /^原物料|終端|通路|待查/i.test(text)) return null; + const focal = String(focalSymbol || '').toUpperCase(); + const paren = text.match(/\(([A-Z]{1,5})\)/); + if (paren && paren[1] !== focal) return paren[1]; + const dollar = text.match(/\$([A-Z]{1,5})\b/); + if (dollar && dollar[1] !== focal) return dollar[1]; + if (isUsTicker(text) && text !== focal) return text; + if (isTwTicker(text)) { + const tw = text.replace(/\.tw$/i, ''); + return `${tw}.TW`; + } + if (NAME_ALIASES[text]) return NAME_ALIASES[text]; + const stripped = text.replace(/\s+(Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.|公司|股份|集團)/gi, '').trim(); + if (NAME_ALIASES[stripped]) return NAME_ALIASES[stripped]; + for (const [name, sym] of Object.entries(NAME_ALIASES)) { + if (name.length < 2) continue; + if (text.includes(name) && sym !== focal) return sym; + } + const inc = text.match(/^([A-Za-z][A-Za-z0-9&.\- ]{1,30})(?:\s+Inc\.|\s+Corp\.)/); + if (inc) { + const base = inc[1].trim(); + if (NAME_ALIASES[base]) return NAME_ALIASES[base]; + const words = base.split(/\s+/); + const last = words[words.length - 1]; + if (last && NAME_ALIASES[last]) return NAME_ALIASES[last]; + } + return null; +} + +export function normalizeEntityItem(raw, focalSymbol = '') { + if (raw && typeof raw === 'object') { + const name = String(raw.name || raw.label || raw.symbol || '').trim(); + const symbol = raw.symbol || resolveEntitySymbol(name, focalSymbol) || resolveEntitySymbol(raw.symbol, focalSymbol); + return { name: name || symbol || '—', symbol: symbol || null }; + } + const name = String(raw || '').trim(); + const symbol = resolveEntitySymbol(name, focalSymbol); + return { name, symbol }; +} + +function normalizeDetailGroups(groups, focalSymbol) { + return (groups || []).map(g => { + const entities = (g.entities || []).map(e => normalizeEntityItem(e, focalSymbol)); + return { ...g, entities }; + }); +} + +function groupFromItems(items, label, note) { + const ents = items.filter(i => i.name).slice(0, 10); + if (!ents.length) return null; + return { label, entities: ents, note }; +} + +/** 從近期新聞標題/摘要抽出上下游相關公司 */ +export function extractChainFromNews(newsList = [], focalSymbol = '') { + const focal = String(focalSymbol || '').toUpperCase(); + const upstream = []; + const downstream = []; + const related = []; + const seen = new Set([focal]); + + const add = (bucket, item) => { + const sym = item.symbol || item.name; + const key = (sym || '').toUpperCase(); + if (!key || seen.has(key)) return; + seen.add(key); + bucket.push(item); + }; + + for (const n of newsList) { + const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`; + if (!text.trim()) continue; + const up = UP_KW.test(text); + const down = DOWN_KW.test(text); + + for (const m of text.matchAll(/\$([A-Z]{1,5})\b|\(([A-Z]{1,5})\)/g)) { + const sym = (m[1] || m[2] || '').toUpperCase(); + if (!isUsTicker(sym) || sym === focal) continue; + const item = normalizeEntityItem(sym, focal); + if (up && !down) add(upstream, item); + else if (down && !up) add(downstream, item); + else add(related, item); + } + + for (const [name, sym] of Object.entries(NAME_ALIASES)) { + if (!text.includes(name) || sym === focal) continue; + const item = normalizeEntityItem(name, focal); + item.symbol = sym; + if (up && !down) add(upstream, item); + else if (down && !up) add(downstream, item); + else add(related, item); + } + } + + for (const n of newsList) { + const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`; + if (!/供應商|供货商|supplier|vendor|foundry|代工/i.test(text)) continue; + for (const [name, sym] of Object.entries(NAME_ALIASES)) { + if (!text.includes(name) || sym === focal) continue; + add(upstream, normalizeEntityItem(name, focal)); + } + } + + for (const n of newsList) { + const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`; + const buyerCtx = OEM_BUYER_CTX.test(text) || (GPU_NEWS_CTX.test(text) && /Dell|HPE|Super|伺服器|server|OEM/i.test(text)); + if (!buyerCtx) continue; + for (const [name, sym] of Object.entries(NAME_ALIASES)) { + if (!DOWNSTREAM_BUYER_SYMS.has(sym) || sym === focal || !text.includes(name)) continue; + const item = normalizeEntityItem(name, focal); + item.symbol = sym; + add(downstream, item); + } + } + + return { upstream, downstream, related }; +} + +export const SECTOR_SUPPLIER_TICKERS = { + semiconductor: ['TSM', 'ASML', 'AMAT', 'LRCX', 'KLAC', 'MU', 'SNPS', 'CDNS'], + software: ['MSFT', 'AMZN', 'GOOGL'], +}; + +/** GPU/加速器晶片常見下游:OEM 伺服器廠 + 雲端買方 */ +export const SECTOR_DOWNSTREAM_BUYERS = { + semiconductor: [ + { label: 'AI 伺服器 OEM', tickers: ['DELL', 'HPE', 'SMCI', 'CSCO'], note: '採購 GPU 組裝 AI 伺服器再銷售' }, + { label: '雲端與大型企業', tickers: ['MSFT', 'AMZN', 'GOOGL', 'META', 'ORCL'], note: '資料中心與 AI 工作負載' }, + ], +}; + +const SYMBOL_DOWNSTREAM_SECTOR = { + NVDA: 'semiconductor', + AMD: 'semiconductor', + INTC: 'semiconductor', + MRVL: 'semiconductor', +}; + +function isGpuSemiconductor(symbol, profile = {}) { + const sym = String(symbol || '').toUpperCase(); + if (SYMBOL_DOWNSTREAM_SECTOR[sym]) return true; + const ind = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase(); + return /semiconductor|chip/i.test(ind) && /graphic|gpu|accelerat|comput|processor|display/i.test(ind); +} + +export function inferDownstreamGroups(symbol, profile = {}) { + const key = SYMBOL_DOWNSTREAM_SECTOR[String(symbol || '').toUpperCase()] + || (isGpuSemiconductor(symbol, profile) ? 'semiconductor' : null); + if (!key || !SECTOR_DOWNSTREAM_BUYERS[key]) return []; + return SECTOR_DOWNSTREAM_BUYERS[key].map(b => ({ + label: b.label, + entities: b.tickers.map(code => ({ + name: code, + symbol: code, + confidence: 'medium', + source: 'sector_downstream', + })), + note: b.note, + confidence: 'medium', + })); +} + +function downstreamHasTradableBuyers(detail, focalSymbol) { + const focal = String(focalSymbol || '').toUpperCase(); + return (detail || []).some(g => + (g.entities || []).some(e => { + const item = normalizeEntityItem(e, focal); + return item.symbol && isTradableSymbol(item.symbol) && item.symbol !== focal; + }), + ); +} + +function isGenericDownstreamGroup(g, focalSymbol) { + const ents = g?.entities || []; + if (!ents.length) return true; + return ents.every(e => { + const item = normalizeEntityItem(e, focalSymbol); + return !item.symbol || !isTradableSymbol(item.symbol); + }); +} + +export function ensureDownstreamBuyers(chain, symbol, profile = {}) { + let next = chain || {}; + if (!downstreamHasTradableBuyers(next.downstreamDetail, symbol)) { + const inferred = inferDownstreamGroups(symbol, profile); + if (inferred.length) { + next = { + ...next, + downstreamDetail: dedupeGroups([...inferred, ...(next.downstreamDetail || [])]), + chainSources: [...new Set([...(next.chainSources || []), '產業常見購買方'])], + }; + } + } + if (downstreamHasTradableBuyers(next.downstreamDetail, symbol)) { + next = { + ...next, + downstreamDetail: (next.downstreamDetail || []).filter(g => !isGenericDownstreamGroup(g, symbol)), + }; + } + return finalizeIndustryChain(next, symbol); +} + +/** 一律追加供應商/客戶名單(去重,不覆蓋既有分組) */ +export function appendDetailNames(detail, names, label, note, focalSymbol = '') { + const incoming = (names || []).map(n => normalizeEntityItem(n, focalSymbol)); + return mergeDetailGroups(detail, incoming, label, note); +} + +function mergeDetailGroups(existing, incoming, label, note) { + const out = [...(existing || [])]; + if (!incoming.length) return out; + const flat = out.flatMap(g => g.entities || []); + const have = new Set(flat.map(e => (e.symbol || e.name || '').toUpperCase())); + const fresh = incoming.filter(e => { + const k = (e.symbol || e.name || '').toUpperCase(); + return k && !have.has(k); + }); + if (!fresh.length) return out; + out.unshift({ label, entities: fresh, note }); + return out.slice(0, 8); +} + +/** 合併新聞萃取進產業鏈 */ +export function mergeNewsIntoChain(chain, newsList, focalSymbol) { + const base = chain || {}; + const { upstream, downstream, related } = extractChainFromNews(newsList, focalSymbol); + let upstreamDetail = mergeDetailGroups(base.upstreamDetail, upstream, '供應商/合作(新聞)', '近期公開新聞'); + let downstreamDetail = mergeDetailGroups(base.downstreamDetail, downstream, '購買方(新聞)', '近期公開新聞'); + let peers = [...(base.peers || [])]; + for (const r of related) { + const sym = r.symbol; + if (sym && !peers.includes(sym) && sym !== focalSymbol) peers.push(sym); + } + peers = peers.filter(p => String(p).toUpperCase() !== focalSymbol).slice(0, 14); + + return finalizeIndustryChain({ + ...base, + upstreamDetail, + downstreamDetail, + peers, + chainSources: [...new Set([...(base.chainSources || []), upstream.length || downstream.length ? '近期新聞' : null].filter(Boolean))], + }, focalSymbol); +} + +/** 統一實體格式、補代號、重算 flat 列表 */ +export function finalizeIndustryChain(chain, focalSymbol = '') { + const focal = String(focalSymbol || '').toUpperCase(); + let upstreamDetail = normalizeDetailGroups(chain.upstreamDetail, focal) + .filter(g => (g.entities || []).length > 0); + let downstreamDetail = normalizeDetailGroups(chain.downstreamDetail, focal) + .filter(g => (g.entities || []).length > 0); + + if (!upstreamDetail.length && Array.isArray(chain.upstream)) { + upstreamDetail = [{ label: '上游', entities: chain.upstream.map(e => normalizeEntityItem(e, focal)), note: '' }]; + } + if (!downstreamDetail.length && Array.isArray(chain.downstream)) { + downstreamDetail = [{ label: '下游', entities: chain.downstream.map(e => normalizeEntityItem(e, focal)), note: '' }]; + } + + let peers = (chain.peers || []).map(p => { + const item = normalizeEntityItem(p, focal); + const sym = item.symbol || (isTradableSymbol(String(p)) ? String(p).toUpperCase() : null); + return isTradableSymbol(sym) ? sym : null; + }).filter(Boolean); + peers = [...new Set(peers)].filter(p => p !== focal).slice(0, 14); + + const flatUp = upstreamDetail.flatMap(g => (g.entities || []).map(e => e.name)).filter(Boolean); + const flatDown = downstreamDetail.flatMap(g => (g.entities || []).map(e => e.name)).filter(Boolean); + + return { + ...chain, + upstream: flatUp.length ? flatUp : chain.upstream, + downstream: flatDown.length ? flatDown : chain.downstream, + upstreamDetail, + downstreamDetail, + peers, + searches: [], + }; +} + +const SUPPLIER_GROUP_RE = /供應|10-K|新聞|產業常見|合作/i; +const CUSTOMER_GROUP_RE = /客戶|購買|買方|OEM|ODM|伺服器|雲端|hyperscale|需求|10-K|新聞|產業/i; + +function groupKey(g) { + return String(g?.label || '').trim(); +} + +function hasTradableEntity(g, focalSymbol = '') { + return (g?.entities || []).some(e => { + const item = normalizeEntityItem(e, focalSymbol); + return item.symbol && isTradableSymbol(item.symbol); + }); +} + +function isNamedSupplierGroup(g, focalSymbol = '') { + return SUPPLIER_GROUP_RE.test(g?.label || '') || hasTradableEntity(g, focalSymbol); +} + +function isNamedCustomerGroup(g, focalSymbol = '') { + return CUSTOMER_GROUP_RE.test(g?.label || '') || hasTradableEntity(g, focalSymbol); +} + +function dedupeGroups(groups) { + const out = []; + const seen = new Set(); + for (const g of groups || []) { + const k = groupKey(g); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(g); + } + return out; +} + +/** 合併 AI 產業鏈時保留已抓到的供應商/客戶分組,避免被泛稱覆蓋 */ +export function mergeEnrichedChain(base = {}, enriched = {}, focalSymbol = '') { + const bUp = base.upstreamDetail || []; + const bDown = base.downstreamDetail || []; + const eUp = enriched.upstreamDetail || enriched.upstream || []; + const eDown = enriched.downstreamDetail || enriched.downstream || []; + + const keepSuppliers = bUp.filter(g => isNamedSupplierGroup(g, focalSymbol)); + const keepCustomers = bDown.filter(g => isNamedCustomerGroup(g, focalSymbol)); + const eUpList = Array.isArray(eUp) ? eUp : []; + const eDownList = Array.isArray(eDown) ? eDown : []; + + let upstreamDetail = dedupeGroups([ + ...keepSuppliers, + ...eUpList.filter(g => !keepSuppliers.some(k => groupKey(k) === groupKey(g))), + ]); + let downstreamDetail = dedupeGroups([ + ...keepCustomers, + ...eDownList.filter(g => !keepCustomers.some(k => groupKey(k) === groupKey(g))), + ]); + + if (!upstreamDetail.length && eUpList.length) upstreamDetail = eUpList; + if (!downstreamDetail.length && eDownList.length) downstreamDetail = eDownList; + + let chain = { + ...base, + ...enriched, + upstreamDetail, + downstreamDetail, + + peers: [...new Set([...(base.peers || []), ...(enriched.peers || [])])], + tenKExcerpt: sanitizeChainExcerpt(enriched.tenKExcerpt || base.tenKExcerpt), + chainSources: [...new Set([...(base.chainSources || []), ...(enriched.chainSources || [])])], + }; + chain = layoutPeersIntoGrid(chain, focalSymbol); + return finalizeIndustryChain(chain, focalSymbol); +} + +/** 同業代號放進上游欄「同業/競爭」分組,不再堆在格子下方 */ +export function layoutPeersIntoGrid(chain, focalSymbol = '') { + const focal = String(focalSymbol || '').toUpperCase(); + const peers = (chain.peers || []) + .map(p => String(p).toUpperCase()) + .filter(p => isTradableSymbol(p) && p !== focal); + if (!peers.length) return { ...chain, peers: [] }; + + const inGrid = new Set(); + for (const g of [...(chain.upstreamDetail || []), ...(chain.downstreamDetail || [])]) { + for (const e of g.entities || []) { + const k = (e.symbol || e.name || '').toUpperCase(); + if (k) inGrid.add(k); + } + } + const peerEntities = peers + .filter(sym => !inGrid.has(sym)) + .map(sym => normalizeEntityItem(sym, focal)); + if (!peerEntities.length) return { ...chain, peers: [] }; + + const upstreamDetail = [...(chain.upstreamDetail || [])]; + const peerLabel = '同業/競爭'; + const exist = upstreamDetail.find(g => groupKey(g) === peerLabel); + if (exist) { + const have = new Set((exist.entities || []).map(e => (e.symbol || e.name || '').toUpperCase())); + for (const e of peerEntities) { + const k = (e.symbol || e.name || '').toUpperCase(); + if (k && !have.has(k)) { exist.entities.push(e); have.add(k); } + } + } else { + upstreamDetail.push({ label: peerLabel, entities: peerEntities, note: '同業標的' }); + } + return { ...chain, upstreamDetail, peers: [] }; +} + +export function sanitizeChainExcerpt(text) { + const t = String(text || '').trim(); + if (!t || t.length < 50) return null; + if (/^nvda-\d|000\d{7,}|\bFY\s+false\b/i.test(t.slice(0, 120))) return null; + return t.slice(0, 480); +} \ No newline at end of file diff --git a/lib/companyintel-i18n.js b/lib/companyintel-i18n.js new file mode 100644 index 0000000..60913c3 --- /dev/null +++ b/lib/companyintel-i18n.js @@ -0,0 +1,154 @@ +// 公司研究資料:欄位中文化(職稱/產業常用對照,非機器翻譯全文) + +const TITLE_ZH = [ + [/chief executive officer|ceo/i, '執行長'], + [/chief financial officer|cfo/i, '財務長'], + [/chief operating officer|coo/i, '營運長'], + [/chief technology officer|cto/i, '技術長'], + [/executive vice president|evp/i, '執行副總'], + [/senior vice president|svp/i, '資深副總'], + [/vice president|vp/i, '副總'], + [/president.*chief executive|president and chief executive/i, '執行長暨總裁'], + [/president/i, '總裁'], + [/general counsel/i, '法務長'], + [/chief accounting officer/i, '會計長'], + [/principal financial officer/i, '主要財務負責人'], + [/principal executive officer/i, '主要執行負責人'], + [/principal accounting officer/i, '主要會計負責人'], + [/director/i, '董事'], + [/chairman/i, '董事長'], + [/operations/i, '營運'], + [/worldwide field/i, '全球業務'], +]; + +const SECTOR_ZH = { + Technology: '科技', + 'Financial Services': '金融服務', + Healthcare: '醫療保健', + 'Consumer Cyclical': '循環性消費', + 'Consumer Defensive': '防禦性消費', + Energy: '能源', + Industrials: '工業', + 'Basic Materials': '原物料', + 'Real Estate': '房地產', + Utilities: '公用事業', + 'Communication Services': '通訊服務', +}; + +const INDUSTRY_HINTS = [ + [/semiconductor/i, '半導體'], + [/software/i, '軟體'], + [/internet/i, '網際網路'], + [/bank/i, '銀行'], + [/biotech|pharma/i, '生技/製藥'], + [/retail/i, '零售'], + [/auto/i, '汽車'], +]; + +export function looksLikePersonName(name) { + if (!name || name.length > 55) return false; + if (/^Item \d/i.test(name) || name === 'Action' || name.startsWith('/s/')) return false; + const n = name.toLowerCase(); + if (/financial|exhibit|schedule|statement|supplementary|governance|table of|designated|hedge|accounting|income|operations|revenue|consolidated|index|former|current|named|other|each|page|directors and|from our|served as/.test(n)) return false; + const parts = name.trim().split(/\s+/); + if (parts.length < 2 || parts.length > 5) return false; + if (!/^[A-Z]/.test(parts[0])) return false; + return parts.every(p => /^[A-Za-z'.-]+$/.test(p)); +} + +export function looksLikeExecutiveTitle(title) { + if (!title || title.length > 100) return false; + if (/financial statement|exhibit|supplementary|schedule|table of contents|designated|hedge|accounting/i.test(title)) return false; + if (/income from|cost of revenue|gross profit|net income|operating income/i.test(title)) return false; + const t = title.toLowerCase(); + if (t === 'director' || t === 'directors') return false; + return /chief|president|executive vice|general counsel|operations|officer|accounting|counsel|field/i.test(t); +} + +export function isOfficerRow(name, title) { + return looksLikePersonName(name) && looksLikeExecutiveTitle(title); +} + +export function sanitizeOfficers(list) { + return (list || []).filter(o => isOfficerRow(o.name, o.title)); +} + +export function translateOfficerTitle(title) { + const t = String(title || '').trim(); + if (!t) return ''; + for (const [re, zh] of TITLE_ZH) { + if (re.test(t)) return zh; + } + return t; +} + +export function translateSector(sector) { + const s = String(sector || '').trim(); + if (!s) return '—'; + return SECTOR_ZH[s] || s; +} + +export function translateIndustry(industry) { + const s = String(industry || '').trim(); + if (!s) return '—'; + for (const [re, zh] of INDUSTRY_HINTS) { + if (re.test(s)) return `${zh}(${s})`; + } + return s; +} + +export function localizeOfficer(o) { + const title = o.titleZh || o.title || ''; + const titleZh = o.titleZh || translateOfficerTitle(title); + return { + ...o, + title, + titleZh, + titleDisplay: titleZh && titleZh !== title ? `${titleZh} · ${title}` : (titleZh || title), + }; +} + +export function mergeCustomIntel(intel, custom) { + if (!custom) return intel; + const out = { ...intel, customUpdatedAt: custom.updatedAt || null }; + if (custom.profileZh) { + out.profileZh = custom.profileZh; + } + if (custom.officers?.length) { + out.management = { + ...out.management, + officers: custom.officers.map(localizeOfficer), + source: '本機自訂', + }; + } + if (custom.news?.length) { + out.news = custom.news.map(n => ({ + ...n, + titleZh: n.titleZh || n.title, + descriptionZh: n.descriptionZh || n.description, + })); + } + if (custom.managementNotes) { + out.management = { ...out.management, notesZh: custom.managementNotes }; + } + return out; +} + +export function localizeIntel(intel) { + if (!intel) return intel; + const officers = sanitizeOfficers(intel.management?.officers || []).map(localizeOfficer); + const news = (intel.news || []).map(n => ({ + ...n, + titleZh: n.titleZh || n.title, + descriptionZh: n.descriptionZh || n.description, + })); + return { + ...intel, + management: { ...intel.management, officers }, + news, + searchesZh: (intel.management?.searches || []).map(s => ({ + ...s, + labelZh: s.labelZh || s.label.replace('Management', '管理層').replace('Leadership', '領導團隊'), + })), + }; +} \ No newline at end of file diff --git a/lib/companyintel-links.js b/lib/companyintel-links.js new file mode 100644 index 0000000..090a8aa --- /dev/null +++ b/lib/companyintel-links.js @@ -0,0 +1,148 @@ +// 公司研究:直接連結(SEC/官網 IR)與 10-K 產業鏈合併(不用 Google 搜尋代替資料) +import { + finalizeIndustryChain, isTradableSymbol, appendDetailNames, SECTOR_SUPPLIER_TICKERS, +} from './companyintel-chain.js'; +const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)'; + +let _tickerMap = null; + +async function json(url, headers = {}, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { headers: { 'User-Agent': SEC_UA, ...headers }, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { clearTimeout(timer); } +} + +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'), + cikNum: Number(d[k].cik_str), + name: d[k].title, + }; + } + } + return _tickerMap[String(symbol || '').toUpperCase()] || null; +} + +function edgarDocUrl(cikNum, accession, primary) { + const accNo = accession.replace(/-/g, ''); + return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNo}/${primary}`; +} + +/** 投資人關係/官網(不經 Google) */ +export function resolveInvestorRelationsUrl(website) { + const w = String(website || '').trim(); + if (!w) return null; + try { + const u = new URL(w.startsWith('http') ? w : `https://${w}`); + const host = u.hostname.replace(/^www\./, ''); + const candidates = [ + u.href, + `${u.protocol}//${u.host}/investor-relations`, + `${u.protocol}//investor.${host}`, + `${u.protocol}//ir.${host}`, + ]; + return { url: candidates[0], labelZh: '公司官網', altUrls: candidates.slice(1) }; + } catch { + return null; + } +} + +/** SEC 直接連結:EDGAR、最新 10-K、DEF 14A */ +export async function buildSecResourceLinks(symbol) { + const hit = await tickerToCik(symbol); + if (!hit) return []; + const links = [ + { labelZh: 'SEC EDGAR 公司頁', url: `https://www.sec.gov/edgar/browse/?CIK=${hit.cik}`, source: 'SEC' }, + ]; + try { + const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA }); + const f = sub.filings?.recent || {}; + const found = { '10-K': null, 'DEF 14A': null, '10-Q': null }; + for (let i = 0; i < (f.form || []).length; i++) { + const form = f.form[i]; + if (!found[form] && found[form] !== undefined) { + const acc = f.accessionNumber[i]; + const doc = f.primaryDocument?.[i]; + if (acc && doc) { + found[form] = edgarDocUrl(hit.cikNum, acc, doc); + } + } + if (found['10-K'] && found['DEF 14A'] && found['10-Q']) break; + } + if (found['10-K']) links.push({ labelZh: '最新 10-K 年報', url: found['10-K'], source: 'SEC' }); + if (found['10-Q']) links.push({ labelZh: '最新 10-Q 季報', url: found['10-Q'], source: 'SEC' }); + if (found['DEF 14A']) links.push({ labelZh: '股東會說明書 DEF 14A', url: found['DEF 14A'], source: 'SEC' }); + } catch { /* */ } + return links; +} + +function entityGroups(names, label, note) { + const list = (names || []).filter(Boolean).slice(0, 10); + if (!list.length) return []; + return [{ label, entities: list, note }]; +} + +/** 把 10-K 抽出的公司名+產業 fallback 合成上下游結構 */ +export function mergeIndustryChainWithHints(symbol, chain, hints = {}, profileExt = {}, profile = {}) { + const base = chain || {}; + const industry = `${profileExt.industry || ''} ${profileExt.sector || profile.industry || ''}`.toLowerCase(); + + let upstreamDetail = base.upstreamDetail?.length ? [...base.upstreamDetail] : []; + let downstreamDetail = base.downstreamDetail?.length ? [...base.downstreamDetail] : []; + let peers = base.peers?.length ? [...base.peers] : [...(profileExt.peers || [])]; + + if (hints.suppliers?.length) { + upstreamDetail = appendDetailNames(upstreamDetail, hints.suppliers, '供應商(10-K)', 'SEC 年報提及', symbol); + } + if (hints.customers?.length) { + downstreamDetail = appendDetailNames(downstreamDetail, hints.customers, '客戶(10-K)', 'SEC 年報提及', symbol); + } + const ind = industry.toLowerCase(); + if (/semiconductor|chip/i.test(ind)) { + upstreamDetail = appendDetailNames( + upstreamDetail, + SECTOR_SUPPLIER_TICKERS.semiconductor, + '產業常見供應商', + '半導體鏈', + symbol, + ); + } + if (hints.competitors?.length) { + const from10k = hints.competitors.map(c => String(c).replace(/\s+(Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.)/i, '').trim().toUpperCase()) + .filter(c => isTradableSymbol(c)); + peers = [...new Set([...peers, ...from10k])].filter(p => p !== symbol).slice(0, 12); + } + + const flatUp = upstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean); + const flatDown = downstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean); + + return finalizeIndustryChain({ + ...base, + upstream: flatUp.length ? flatUp : base.upstream, + downstream: flatDown.length ? flatDown : base.downstream, + upstreamDetail, + downstreamDetail, + peers, + + tenKExcerpt: hints.excerpt ? String(hints.excerpt).slice(0, 480) : base.tenKExcerpt || null, + chainSource: hints.source || base.chainSource || (hints.excerpt ? 'SEC 10-K' : null), + chainSources: [...new Set([...(base.chainSources || []), hints.excerpt ? 'SEC 10-K' : null].filter(Boolean))], + searches: [], + }, symbol); +} + +export async function buildCompanyResources(symbol, profile = {}, management = {}) { + const links = []; + const ir = resolveInvestorRelationsUrl(profile.website || management.website); + if (ir) links.push({ labelZh: '投資人關係/官網', url: ir.url, source: '官網' }); + const sec = await buildSecResourceLinks(symbol).catch(() => []); + return [...links, ...sec]; +} \ No newline at end of file diff --git a/lib/companyintel-refresh.js b/lib/companyintel-refresh.js new file mode 100644 index 0000000..e3bdd32 --- /dev/null +++ b/lib/companyintel-refresh.js @@ -0,0 +1,68 @@ +// 公司研究同步節奏:首次進入必抓;之後等到「下次財報/公開」再更新 +import { fetchEarningsEvents } from './calendar.js'; + +function addDaysISO(base, days) { + const d = new Date(base + 'T12:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +function todayISO() { + return new Date().toISOString().slice(0, 10); +} + +/** 下次允許重新抓取的日期 = 下一個財報日(尚無則約一季後) */ +export async function computeNextPublicRefresh(symbol) { + symbol = String(symbol || '').trim().toUpperCase(); + const today = todayISO(); + const end = addDaysISO(today, 200); + try { + const events = await fetchEarningsEvents(today, end, [symbol]); + const upcoming = (events || []) + .filter(e => e.date && e.date > today) + .sort((a, b) => a.date.localeCompare(b.date)); + if (upcoming.length) { + const next = upcoming[0]; + return { + nextRefreshAfter: next.date, + nextPublicLabel: next.title || `${symbol} 財報`, + nextPublicDate: next.date, + }; + } + } catch { /* fallback */ } + const fallback = addDaysISO(today, 92); + return { + nextRefreshAfter: fallback, + nextPublicLabel: '約一季後(暫無財報日曆)', + nextPublicDate: fallback, + }; +} + +export function intelRefreshPolicy(enrichedRow) { + const data = enrichedRow?.data || {}; + const lastSyncAt = data.lastSyncAt || enrichedRow?.updatedAt || null; + const nextRefreshAfter = data.nextRefreshAfter || null; + const today = todayISO(); + const neverSynced = !lastSyncAt; + const due = nextRefreshAfter ? today >= nextRefreshAfter : false; + const needsSync = neverSynced || due; + let skipReason = null; + if (!needsSync && nextRefreshAfter) { + skipReason = `已同步;下次更新:${nextRefreshAfter}(${data.nextPublicLabel || '下次財報/公開'})`; + } else if (!needsSync) { + skipReason = '已同步'; + } + return { + needsSync, + lastSyncAt: lastSyncAt ? new Date(lastSyncAt).toISOString() : null, + nextRefreshAfter, + nextPublicLabel: data.nextPublicLabel || null, + skipReason, + }; +} + +export function shouldRunIntelSync(enrichedRow, { force = false } = {}) { + if (force) return { run: true, reason: 'force' }; + const p = intelRefreshPolicy(enrichedRow); + return { run: p.needsSync, reason: p.needsSync ? 'first_or_due' : 'wait_public', ...p }; +} \ No newline at end of file diff --git a/lib/companyintel-sources.js b/lib/companyintel-sources.js new file mode 100644 index 0000000..7ee0d14 --- /dev/null +++ b/lib/companyintel-sources.js @@ -0,0 +1,338 @@ +// 公司研究:多來源抓取(台灣/國際新聞、簡介、10-K 供應鏈線索、管理層動態) +import { yahooQuoteSummary, yahooFinanceSearchNews } from './yahoo-session.js'; +import { + cleanNewsPlain, cleanGoogleNewsTitle, parseGoogleRssDescription, normalizeNewsItem, +} from './news-text.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 = 14000) { + 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 = 14000) { + return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms)); +} + +const strip = (s) => cleanNewsPlain(s); +const tag = (block, name) => block.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || ''; + +function parseGoogleRss(xml, region, limit = 12) { + const items = [...String(xml || '').matchAll(/([\s\S]*?)<\/item>/gi)] + .map(m => m[1]) + .slice(0, limit); + return items.map(block => { + const title = cleanGoogleNewsTitle(tag(block, 'title')); + const link = tag(block, 'link') || (block.match(/]*>([^<]+)<\/link>/i)?.[1] || '').trim(); + const pub = tag(block, 'pubDate'); + const { anchorText, fontPub } = parseGoogleRssDescription(tag(block, 'description')); + const sourceName = cleanNewsPlain(tag(block, 'source')); + const publisher = sourceName || fontPub || 'Google 新聞'; + let description = ''; + if (anchorText && anchorText !== title && anchorText.length > 6 && !/news\.google\.com/i.test(anchorText)) { + description = anchorText; + } + return normalizeNewsItem({ + title, + titleZh: title, + description: description.slice(0, 400), + descriptionZh: description.slice(0, 400), + url: link, + publisher, + created: pub ? new Date(pub).toISOString().slice(0, 10) : null, + region, + source: region === 'tw' ? 'Google 新聞(台灣)' : 'Google 新聞(國際)', + }); + }).filter(n => n.titleZh && n.url); +} + +export async function fetchTaiwanNews(symbol, companyName) { + const queries = [ + /NVDA/i.test(symbol) ? '輝達' : null, + `${symbol} 台股`, + `${symbol} 美股`, + companyName && /[\u4e00-\u9fff]/.test(companyName) ? companyName : null, + ].filter(Boolean); + const seen = new Set(); + const out = []; + for (const q of queries) { + try { + const url = `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=zh-TW&gl=TW&ceid=TW:zh-Hant`; + const xml = await text(url, { Accept: 'application/rss+xml, application/xml, text/xml, */*' }, 10000); + for (const item of parseGoogleRss(xml, 'tw', 15)) { + const key = item.url; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + } catch { /* next query */ } + if (out.length >= 12) break; + } + return out.slice(0, 12); +} + +export async function fetchGlobalNews(symbol) { + const out = []; + const seen = new Set(); + try { + const yNews = await yahooFinanceSearchNews(symbol, 14); + for (const n of yNews) { + const item = normalizeNewsItem({ + title: n.title, + titleZh: n.title, + description: strip(n.summary || ''), + descriptionZh: strip(n.summary || ''), + url: n.link, + publisher: n.publisher || 'Yahoo Finance', + created: n.providerPublishTime ? new Date(n.providerPublishTime * 1000).toISOString().slice(0, 10) : null, + region: 'global', + source: 'Yahoo Finance', + }); + if (item.url && !seen.has(item.url)) { seen.add(item.url); out.push(item); } + } + } catch { /* */ } + try { + const y = await json(`https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}&newsCount=12"esCount=0`); + for (const n of y.news || []) { + const item = normalizeNewsItem({ + title: n.title, + titleZh: n.title, + description: strip(n.summary || ''), + descriptionZh: strip(n.summary || ''), + url: n.link, + publisher: n.publisher || 'Yahoo Finance', + created: n.providerPublishTime ? new Date(n.providerPublishTime * 1000).toISOString().slice(0, 10) : null, + region: 'global', + source: 'Yahoo Finance', + }); + if (item.url && !seen.has(item.url)) { seen.add(item.url); out.push(item); } + } + } catch { /* */ } + + for (const q of [`${symbol} stock`, `${symbol} earnings CEO`]) { + try { + const url = `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=en-US&gl=US&ceid=US:en`; + const xml = await text(url, {}, 10000); + for (const item of parseGoogleRss(xml, 'global', 10)) { + if (seen.has(item.url)) continue; + seen.add(item.url); + out.push(item); + } + } catch { /* */ } + if (out.length >= 14) break; + } + + try { + 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/', + }); + for (const r of d?.data?.rows || []) { + const url = r.url ? (r.url.startsWith('http') ? r.url : `https://www.nasdaq.com${r.url}`) : null; + if (!url || seen.has(url)) continue; + seen.add(url); + out.push(normalizeNewsItem({ + title: r.title, + titleZh: r.title, + description: strip(r.description || ''), + descriptionZh: strip(r.description || ''), + url, + publisher: r.publisher || 'Nasdaq', + created: r.created || r.ago, + region: 'global', + source: 'Nasdaq', + })); + } + } catch { /* */ } + + return out.slice(0, 14); +} + +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; +} + +export async function fetchCompanyProfileExtended(symbol, seed = {}) { + if (seed.longBusinessSummary && seed.sector) { + return { + symbol, + longBusinessSummary: seed.longBusinessSummary, + website: seed.website || null, + sector: seed.sector, + industry: seed.industry || null, + country: seed.country || null, + employees: seed.fullTimeEmployees ?? null, + peers: seed.peers || [], + source: seed.source || 'Yahoo assetProfile', + }; + } + let profile = { symbol, longBusinessSummary: null, website: null, sector: null, industry: null, country: null, employees: null, peers: [] }; + try { + const d = await yahooQuoteSummary(symbol, 'assetProfile,summaryProfile,peer'); + const p = d?.assetProfile || {}; + const sp = d?.summaryProfile || {}; + const peers = (d?.peer?.symbols || []) + .map(s => String(s).split('.').pop()?.toUpperCase()).filter(s => s && s !== symbol); + profile = { + symbol, + longBusinessSummary: p.longBusinessSummary || sp.longBusinessSummary || null, + website: p.website || sp.website || null, + sector: p.sector || sp.sector || null, + industry: p.industry || sp.industry || null, + country: p.country || sp.country || null, + employees: p.fullTimeEmployees ?? sp.fullTimeEmployees ?? null, + peers: [...new Set(peers)].slice(0, 12), + source: 'Yahoo quoteSummary', + }; + } catch { /* */ } + return profile; +} + +function extractNamedEntities(section) { + const names = new Set(); + const patterns = [ + /(?:customers?|clients?|suppliers?|competitors?|partners?)[^.]{0,400}/gi, + /\b([A-Z][A-Za-z0-9&.\- ]{2,40}(?:Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.))/g, + ]; + for (const re of patterns) { + for (const m of section.matchAll(re)) { + const chunk = m[1] || m[0]; + const hits = chunk.match(/\b([A-Z][A-Za-z0-9&.\- ]{2,35}(?:Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.))/g) || []; + for (const h of hits) { + const n = h.trim(); + if (n.length > 3 && n.length < 50) names.add(n); + } + } + } + return [...names].slice(0, 15); +} + +function extract10kSuppliers(plain) { + const names = new Set(); + const chunks = [ + plain.match(/(?:suppliers?|supply\s+chain|sole\s+supplier|third[- ]party\s+manufactur)[^.]{0,2000}/gi) || [], + plain.match(/(?:we\s+(?:rely|depend)\s+(?:on|upon)\s+)[^.]{0,800}/gi) || [], + plain.match(/(?:contract\s+manufactur|foundry)[^.]{0,1200}/gi) || [], + ].flat(); + for (const block of chunks) { + for (const n of extractNamedEntities(block)) names.add(n); + for (const m of block.matchAll(/\b(TSMC|Taiwan Semiconductor|Samsung|SK\s*Hynix|Micron|ASML|Synopsys|Cadence|Foxconn|Hon\s*Hai)\b/gi)) { + names.add(m[1].trim()); + } + } + return [...names].slice(0, 18); +} + +function extract10kCustomers(plain) { + const names = new Set(); + const chunks = plain.match(/(?:major\s+customers?|principal\s+customers?|customers?\s+include|accounted\s+for\s+\d+%)[^.]{0,2000}/gi) || []; + for (const block of chunks) { + for (const n of extractNamedEntities(block)) names.add(n); + for (const m of block.matchAll(/\b(Microsoft|Amazon|Google|Alphabet|Meta|Apple|Tesla|Oracle)\b/gi)) { + names.add(m[1].trim()); + } + for (const m of block.matchAll(/\b(Dell\s+Technologies|Hewlett[\s-]?Packard\s+Enterprise|Super\s*Micro\s+Computer|Lenovo|Cisco)\b/gi)) { + names.add(m[1].trim()); + } + } + return [...names].slice(0, 18); +} + +export async function fetch10kChainHints(symbol) { + const hit = await tickerToCik(symbol); + if (!hit) return { excerpt: null, customers: [], suppliers: [], competitors: [] }; + const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA }); + const f = sub.filings?.recent || {}; + let accn = null; + let primary = null; + for (let i = 0; i < (f.form || []).length; i++) { + if (f.form[i] === '10-K') { + accn = f.accessionNumber[i]; + primary = f.primaryDocument?.[i]; + break; + } + } + if (!accn || !primary) return { excerpt: null, customers: [], suppliers: [], competitors: [] }; + const accNo = accn.replace(/-/g, ''); + const url = `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`; + const html = await text(url, { 'User-Agent': SEC_UA }, 28000); + const plain = strip(html).slice(0, 180000); + const custSec = plain.match(/(?:major customers?|principal customers?|customers? include)[^.]{0,1200}/i)?.[0] || ''; + const supSec = plain.match(/(?:suppliers?|supply chain|manufacturing)[^.]{0,1200}/i)?.[0] || ''; + const compSec = plain.match(/(?:competition|competitors?)[^.]{0,1200}/i)?.[0] || ''; + const bizSec = plain.match(/(?:business overview|description of business)[^.]{0,2500}/i)?.[0] || plain.slice(0, 2500); + const customers = [...new Set([...extractNamedEntities(custSec), ...extract10kCustomers(plain)])]; + const suppliers = [...new Set([...extractNamedEntities(supSec), ...extract10kSuppliers(plain)])]; + return { + excerpt: bizSec.slice(0, 2000), + customers, + suppliers, + competitors: extractNamedEntities(compSec), + source: 'SEC 10-K', + filingUrl: url, + companyName: hit.name, + }; +} + +const MGMT_KW = /chief executive|ceo|cfo|coo|president|board|director|executive|resign|appoint|compensation|guidance|layoff|restructur|merger|acquisition|investigation|subpoena|執行長|財務長|董事|人事|裁員|併購|收購|指引|調查/i; + +export function filterManagementNews(news) { + return (news || []).filter(n => MGMT_KW.test(`${n.title} ${n.description}`)).slice(0, 10); +} + +export async function fetchRecent8kHeadlines(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 out = []; + for (let i = 0; i < (f.form || []).length && out.length < 8; i++) { + if (!/^8-K/i.test(f.form[i])) continue; + out.push({ + form: f.form[i], + filedDate: f.filingDate[i], + description: f.primaryDocDescription?.[i] || '', + accession: f.accessionNumber[i], + url: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${hit.cik}&type=8-K&dateb=&owner=include&count=40`, + }); + } + return out; +} + +export async function gatherIntelSources(symbol, profile = {}) { + symbol = String(symbol || '').trim().toUpperCase(); + const [profileExt, hints, headlines] = await Promise.all([ + fetchCompanyProfileExtended(symbol, profile).catch(() => ({})), + fetch10kChainHints(symbol).catch(() => ({})), + fetchRecent8kHeadlines(symbol).catch(() => []), + ]); + const companyName = profile.name || profile.companyName || hints?.companyName || null; + const [newsTw, newsGlobal] = await Promise.all([ + fetchTaiwanNews(symbol, companyName).catch(() => []), + fetchGlobalNews(symbol).catch(() => []), + ]); + const mgmtRaw = filterManagementNews([...newsTw, ...newsGlobal]); + return { + symbol, + gatheredAt: new Date().toISOString(), + profileExt, + hints, + headlines8k: headlines, + newsTw, + newsGlobal, + managementNewsRaw: mgmtRaw, + companyName: companyName || hints?.companyName || profileExt?.symbol, + }; +} \ No newline at end of file diff --git a/lib/companyintel.js b/lib/companyintel.js index a5f1b49..1a3c4d7 100644 --- a/lib/companyintel.js +++ b/lib/companyintel.js @@ -1,6 +1,30 @@ // ═══════════════════════════════════════════════════════════ -// companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈入口 +// companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈 // ═══════════════════════════════════════════════════════════ +import { getCompanyIntelCustom, getCompanyIntelEnriched } from './db.js'; +import { localizeIntel, mergeCustomIntel, sanitizeOfficers, isOfficerRow, looksLikePersonName, looksLikeExecutiveTitle } from './companyintel-i18n.js'; +import { gatherIntelSources, fetch10kChainHints } from './companyintel-sources.js'; +import { mergeIndustryChainWithHints, buildCompanyResources } from './companyintel-links.js'; +import { + mergeNewsIntoChain, finalizeIndustryChain, layoutPeersIntoGrid, sanitizeChainExcerpt, ensureDownstreamBuyers, +} from './companyintel-chain.js'; +import { applyEnrichedToIntel, syncCompanyIntelEnriched, attachIntelSyncStatus } from './companyintel-ai.js'; +import { normalizeNewsList } from './news-text.js'; + +/** API 快取命中時仍清理新聞欄位(舊快取可能含 Google RSS 跳脫 HTML) */ +export function sanitizeIntelNewsPayload(payload) { + if (!payload || typeof payload !== 'object') return payload; + const newsTw = normalizeNewsList(payload.newsTw); + const newsGlobal = normalizeNewsList(payload.newsGlobal); + return { + ...payload, + newsTw, + newsGlobal, + news: normalizeNewsList(payload.news?.length ? payload.news : [...newsTw, ...newsGlobal]).slice(0, 20), + }; +} +import { yahooQuoteSummary, resetYahooAuth, sleep } from './yahoo-session.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)'; @@ -22,7 +46,7 @@ const num = (s) => { 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; +const tag = (src, name) => src.match(new RegExp(`<${name}>([\\s\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || null; let _tickerMap = null; async function tickerToCik(symbol) { @@ -34,35 +58,23 @@ async function tickerToCik(symbol) { 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 || {}; + const r = await yahooQuoteSummary(symbol, 'assetProfile'); + const p = r?.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 => ({ + longBusinessSummary: p.longBusinessSummary || null, + officers: sanitizeOfficers((p.companyOfficers || []).slice(0, 12).map(o => ({ name: o.name || '', title: o.title || '', age: o.age ?? null, fiscalYear: o.fiscalYear ?? null, totalPay: o.totalPay?.raw ?? null, - })), + }))).filter(o => o.name), source: 'Yahoo assetProfile', }; } catch { @@ -70,6 +82,125 @@ async function fetchManagement(symbol) { } } +/** Yahoo 限流時重試;仍失敗則用 SEC 10-K(僅美股) */ +async function resolveManagement(symbol) { + let m = await fetchManagement(symbol); + if ((m.officers || []).length >= 2) return m; + await sleep(800); + resetYahooAuth(); + const retry = await fetchManagement(symbol); + if ((retry.officers || []).length > (m.officers || []).length) m = retry; + if ((m.officers || []).length >= 2) return m; + const secOfficers = sanitizeOfficers(await fetchOfficersFromSec10k(symbol).catch(() => [])); + if (secOfficers.length) { + return { ...m, officers: secOfficers, source: 'SEC 10-K' }; + } + const defOfficers = await fetchOfficersFromDef14a(symbol).catch(() => []); + if (defOfficers.length) { + return { ...m, officers: defOfficers, source: 'SEC DEF 14A' }; + } + return m; +} + +/** 從股東會說明書(DEF 14A)抓高管:Yahoo/10-K 都失敗時用(例如 AAPL) */ +async function fetchOfficersFromDef14a(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 || {}; + let accn = null; + let primary = null; + for (let i = 0; i < (f.form || []).length; i++) { + if (f.form[i] === 'DEF 14A') { + accn = f.accessionNumber[i]; + primary = f.primaryDocument?.[i]; + break; + } + } + if (!accn || !primary) return []; + const accNo = accn.replace(/-/g, ''); + const html = await text(`https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`, { 'User-Agent': SEC_UA }, 28000); + const uniq = new Map(); + const addPair = (name, title) => { + if (!isOfficerRow(name, title)) return; + uniq.set(name.toLowerCase(), { name, title: stripHtml(title), source: 'SEC DEF 14A' }); + }; + const election = html.match(/Election of Directors:\s*([^<]{20,400})/i)?.[1]; + if (election) { + for (const name of election.split(',').map(s => stripHtml(s)).filter(Boolean)) { + if (!looksLikePersonName(name)) continue; + addPair(name, 'Director'); + } + } + for (const label of ['Chief Executive Officer', 'Chief Financial Officer', 'Chief Operating Officer', 'Senior Vice President', 'General Counsel']) { + let idx = 0; + while (uniq.size < 14) { + idx = html.indexOf(label, idx); + if (idx < 0) break; + const before = stripHtml(html.slice(Math.max(0, idx - 160), idx)); + const nameM = before.match(/([A-Z][a-z]+(?:\s+[A-Z]\.?)?\s+[A-Z][a-z]+)\s*$/); + if (nameM) addPair(nameM[1], label); + idx += label.length; + } + } + for (const name of [...uniq.keys()]) { + const display = uniq.get(name).name; + const pos = html.indexOf(display); + if (pos < 0) continue; + const chunk = html.slice(pos, pos + 520); + const titleM = chunk.match(/((?:Former\s+)?(?:Senior|Executive|Chief|General)[\s\S]{8,120}?)(?=\s* o.title !== 'Director' || uniq.size <= 4).slice(0, 12)); +} + +const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim(); + +async function fetchOfficersFromSec10k(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 || {}; + let accn = null; + let primary = null; + for (let i = 0; i < (f.form || []).length; i++) { + if (f.form[i] === '10-K') { + accn = f.accessionNumber[i]; + primary = f.primaryDocument?.[i]; + break; + } + } + if (!accn || !primary) return []; + const accNo = accn.replace(/-/g, ''); + const url = `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`; + const html = await text(url, { 'User-Agent': SEC_UA }, 25000); + const item10 = html.search(/Item\s*10[\s\S]{0,120}(Executive Officers|Directors)/i); + const slice = item10 >= 0 ? html.slice(item10, item10 + 120000) : html.slice(0, 120000); + const rows = [...slice.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map(m => m[1]); + const officers = []; + for (const row of rows) { + const cells = [...row.matchAll(/]*>([\s\S]*?)<\/t[dh]>/gi)].map(m => stripHtml(m[1])); + if (cells.length < 2) continue; + const title = cells.find(c => /Chief|President|Officer|Counsel|Operations|Financial|Accounting|Field/i.test(c) && c.length < 140); + const name = cells.find(c => + c.length > 3 && c.length < 70 + && !/Chief|President|Officer|Director|Age|Name|Title|NVIDIA|Common|Stock|Item|Action/i.test(c) + && /[A-Za-z]/.test(c), + ); + if (!isOfficerRow(name, title)) continue; + officers.push({ name, title, source: 'SEC 10-K' }); + } + const uniq = new Map(); + for (const o of officers) { + const key = o.name.toLowerCase(); + if (!uniq.has(key)) uniq.set(key, o); + } + return [...uniq.values()].slice(0, 12); +} + function parseForm4(txt, filing) { const xml = txt.slice(txt.indexOf('([\s\S]*?)<\/reportingOwner>/i)?.[1] || ''; @@ -100,6 +231,7 @@ function parseForm4(txt, filing) { url: filing.url, }; } + async function fetchInsiderTransactions(symbol) { const hit = await tickerToCik(symbol); if (!hit) return []; @@ -124,81 +256,210 @@ async function fetchInsiderTransactions(symbol) { 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 = {}) { +function industryChainFallback(symbol, profile = {}) { const industry = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase(); const maps = [ { match: /semiconductor|chip|accelerated|technology/, upstream: ['EDA/IP 軟體', '晶圓代工', '先進封裝', 'HBM/記憶體', '半導體設備', 'ABF/載板'], + upstreamNamed: ['TSM', 'ASML', 'AMAT', 'LRCX', 'KLAC', 'MU', 'SNPS', 'CDNS'], peers: ['AMD', 'AVGO', 'QCOM', 'MRVL', 'TSM', 'ASML', 'MU'], - downstream: ['雲端資料中心', 'AI 伺服器 OEM/ODM', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'], + downstream: ['雲端資料中心', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'], + downstreamNamed: [ + { label: 'AI 伺服器 OEM', entities: ['DELL', 'HPE', 'SMCI'], note: '採購 GPU 組裝銷售' }, + { label: '雲端與大型企業', entities: ['MSFT', 'AMZN', 'GOOGL', 'META'], note: '資料中心 GPU 需求' }, + ], + midstream: { role: '晶片設計/GPU 平台', segments: ['資料中心 GPU', '遊戲 GPU', '軟體 CUDA'] }, }, { 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: ['消費者', '會員訂閱', '門市/電商通路'], + midstream: { role: '軟體/平台', segments: ['訂閱', '廣告', '雲端服務'] }, }, ]; const hit = maps.find(m => m.match.test(industry)) || { - upstream: ['原物料/零組件', '設備與服務供應商', '物流與通路', '資本支出供應商'], + upstream: ['原物料/零組件', '設備與服務供應商'], + upstreamNamed: [], peers: [], - downstream: ['終端客戶', '企業採購', '通路夥伴', '替代產品'], + downstream: ['終端客戶', '企業採購', '通路夥伴'], + downstreamNamed: [], + midstream: { role: profile.industry || '核心業務', segments: [] }, }; - const q = encodeURIComponent(`${symbol} suppliers customers upstream downstream competitors`); + const upDetail = hit.upstreamNamed?.length + ? [{ label: '供應商', entities: hit.upstreamNamed, note: '產業鏈慣例' }, + ...hit.upstream.map(u => ({ label: u, entities: [u], note: '' }))] + : hit.upstream.map(u => ({ label: u, entities: [u], note: '' })); return { upstream: hit.upstream, - peers: hit.peers.filter(s => s !== symbol), + upstreamDetail: upDetail, 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`)}` }, - ], + downstreamDetail: (hit.downstreamNamed?.length + ? hit.downstreamNamed.map(d => ({ + label: d.label || '購買方', + entities: d.entities || [], + note: d.note || '產業鏈慣例', + })) + : []).concat(hit.downstream.map(d => ({ label: d, entities: [d], note: '' }))), + peers: hit.peers.filter(s => s !== symbol), }; } -export async function getCompanyIntel(symbol, profile = {}) { +/** 完整同步:多來源新聞 + AI 結構化 + 寫入 DB */ +export async function runCompanyIntelSync(symbol, profile = {}, opts = {}) { + const management = await resolveManagement(symbol); + return syncCompanyIntelEnriched(symbol, { ...profile, ...management }, { + force: opts.force === true, + useAI: opts.useAI !== false, + management, + }); +} + +function buildDataHealth(fields) { + const notes = []; + if (!fields.officers) notes.push('管理層名單未取得(可按「強制更新」重試)'); + if (!fields.newsTw && !fields.newsGlobal) notes.push('新聞來源暫時無回應'); + if (!fields.insiders && fields.usListing) notes.push('近期無 SEC Form 4 或 CIK 對應失敗'); + if (!fields.insiders && !fields.usListing) notes.push('非美股標的,無 SEC 內部人申報'); + if (!fields.profileDesc) notes.push('公司簡介待同步後整理為中文'); + return { ...fields, notes }; +} + +export async function getCompanyIntel(symbol, profile = {}, opts = {}) { symbol = String(symbol || '').trim().toUpperCase(); - const [management, insiders, news] = await Promise.all([ - fetchManagement(symbol), - fetchInsiderTransactions(symbol).catch(() => []), - fetchNews(symbol).catch(() => []), - ]); - return { + const management = await resolveManagement(symbol); + const usListing = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.'); + + let bundle = null; + let enrichedRow = getCompanyIntelEnriched(symbol); + if (opts.sync) { + const sync = await runCompanyIntelSync(symbol, { ...profile, ...management }, { force: opts.force, useAI: opts.useAI }); + bundle = sync.bundle; + enrichedRow = { data: sync.enriched, sources: sync.sources, updatedAt: Date.now() }; + } else if (!enrichedRow) { + bundle = await gatherIntelSources(symbol, { ...profile, name: profile.name, ...management }).catch(() => null); + } + + const insiders = usListing + ? await fetchInsiderTransactions(symbol).catch(() => []) + : []; + + let newsTw = bundle?.newsTw || []; + let newsGlobal = bundle?.newsGlobal || []; + if (!newsTw.length && !newsGlobal.length && !opts.sync) { + const b = await gatherIntelSources(symbol, { ...profile, ...management }).catch(() => null); + if (b) { + newsTw = b.newsTw || []; + newsGlobal = b.newsGlobal || []; + bundle = b; + } + } + + const custom = getCompanyIntelCustom(symbol); + let industryChain = industryChainFallback(symbol, { ...profile, ...management }); + const hints = bundle?.hints || (usListing && !opts.sync + ? await fetch10kChainHints(symbol).catch(() => ({})) + : {}); + if (hints && Object.keys(hints).length) { + industryChain = mergeIndustryChainWithHints( + symbol, + industryChain, + hints, + bundle?.profileExt || {}, + { ...profile, ...management }, + ); + } + let profileZh = management.longBusinessSummary + ? { description: management.longBusinessSummary.slice(0, 500), businessModel: management.industry || profile.industry || '' } + : (bundle?.profileExt?.longBusinessSummary + ? { description: bundle.profileExt.longBusinessSummary.slice(0, 500), businessModel: bundle.profileExt.industry || '' } + : null); + + const raw = { 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`)}` }, - ], - }, + profileZh, + management: { ...management, searches: [] }, insiders, - news, - industryChain: industryChain(symbol, { ...profile, ...management }), - sources: ['Yahoo assetProfile', 'SEC Form 4', 'Nasdaq News'], + news: normalizeNewsList([...newsTw, ...newsGlobal]).slice(0, 20), + newsTw: normalizeNewsList(newsTw), + newsGlobal: normalizeNewsList(newsGlobal), + managementBrief: (bundle?.managementNewsRaw || []).slice(0, 6).map(n => ({ + date: n.created, + headline: n.titleZh || n.title, + summary: (n.descriptionZh || n.description || '').slice(0, 160), + impact: 'neutral', + source: n.publisher, + url: n.url, + })), + industryChain, + sources: [ + management.source || 'Yahoo assetProfile', + 'SEC Form 4', + 'Google 新聞(台灣)', + 'Google 新聞(國際)', + 'Nasdaq / Yahoo Finance', + ...(enrichedRow?.sources || []), + ...(custom ? ['本機自訂'] : []), + ].filter(Boolean), + customUpdatedAt: custom?.updatedAt ? new Date(custom.updatedAt).toISOString() : null, + enrichedAt: enrichedRow?.updatedAt ? new Date(enrichedRow.updatedAt).toISOString() : null, + aiEnriched: enrichedRow?.data?.aiUsed || false, + enrichSources: enrichedRow?.sources || [], }; -} + + let intel = mergeCustomIntel(localizeIntel(raw), custom?.data); + if (enrichedRow?.data) { + intel = applyEnrichedToIntel(intel, { ...enrichedRow.data, sources: enrichedRow.sources }); + } + intel.newsTw = normalizeNewsList(intel.newsTw); + intel.newsGlobal = normalizeNewsList(intel.newsGlobal); + intel.news = normalizeNewsList(intel.news?.length ? intel.news : [...intel.newsTw, ...intel.newsGlobal]).slice(0, 20); + if (hints && Object.keys(hints).length) { + intel.industryChain = mergeIndustryChainWithHints( + symbol, + intel.industryChain, + hints, + bundle?.profileExt || {}, + { ...profile, ...management }, + ); + } + const allNews = [...(intel.newsTw || []), ...(intel.newsGlobal || []), ...(intel.news || [])]; + intel.industryChain = ensureDownstreamBuyers( + layoutPeersIntoGrid( + finalizeIndustryChain(mergeNewsIntoChain(intel.industryChain, allNews, symbol), symbol), + symbol, + ), + symbol, + { ...profile, ...management }, + ); + if (intel.industryChain.tenKExcerpt) { + intel.industryChain.tenKExcerpt = sanitizeChainExcerpt(intel.industryChain.tenKExcerpt); + } + const resources = usListing + ? await buildCompanyResources(symbol, { ...profile, website: management.website }, management).catch(() => []) + : []; + if (hints?.filingUrl) { + resources.unshift({ labelZh: '10-K 年報全文', url: hints.filingUrl, source: 'SEC' }); + } + const seenUrl = new Set(); + intel.resources = resources.filter(l => { + if (!l?.url || seenUrl.has(l.url)) return false; + seenUrl.add(l.url); + return true; + }); + intel.management = { ...intel.management, searches: [], resources }; + intel.chainLayout = enrichedRow?.data?.chainLayout || 'upstream_downstream_v2'; + intel = attachIntelSyncStatus(intel, symbol); + intel.dataHealth = buildDataHealth({ + officers: (intel.management?.officers || []).length > 0, + newsTw: (intel.newsTw || []).length > 0, + newsGlobal: (intel.newsGlobal || []).length > 0, + insiders: insiders.length > 0, + profileDesc: !!(intel.profileZh?.description?.length > 40), + enriched: !!(intel.enrichedAt || intel.aiEnriched), + usListing, + }); + return sanitizeIntelNewsPayload(intel); +} \ No newline at end of file diff --git a/lib/db.js b/lib/db.js index a2df07e..26c5a43 100644 --- a/lib/db.js +++ b/lib/db.js @@ -32,6 +32,70 @@ db.exec(` score INTEGER NOT NULL, regime TEXT ); + CREATE TABLE IF NOT EXISTS price_bars ( + symbol TEXT NOT NULL, + interval TEXT NOT NULL DEFAULT '1d', + date TEXT NOT NULL, + open REAL, + high REAL, + low REAL, + close REAL, + volume REAL, + adjclose REAL, + PRIMARY KEY (symbol, interval, date) + ); + CREATE INDEX IF NOT EXISTS idx_price_bars_sym ON price_bars(symbol, interval, date); + CREATE TABLE IF NOT EXISTS company_intel_custom ( + symbol TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sec_filings ( + symbol TEXT NOT NULL, + accession TEXT NOT NULL, + form TEXT, + form_zh TEXT, + filed_date TEXT, + report_date TEXT, + description TEXT, + primary_document TEXT, + url TEXT, + local_primary TEXT, + local_txt TEXT, + excerpt TEXT, + is_earnings_related INTEGER DEFAULT 0, + earnings_exhibits TEXT, + archived_at INTEGER, + PRIMARY KEY (symbol, accession) + ); + CREATE INDEX IF NOT EXISTS idx_sec_filings_sym ON sec_filings(symbol, filed_date DESC); + CREATE TABLE IF NOT EXISTS earnings_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + event_date TEXT, + title TEXT, + title_zh TEXT, + time_label TEXT, + source TEXT, + url TEXT, + note TEXT, + kind TEXT, + accession TEXT, + transcript_search_url TEXT, + UNIQUE(symbol, event_date, kind, accession, title) + ); + CREATE INDEX IF NOT EXISTS idx_earnings_sym ON earnings_events(symbol, event_date DESC); + CREATE TABLE IF NOT EXISTS sec_archive_meta ( + symbol TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS company_intel_enriched ( + symbol TEXT PRIMARY KEY, + payload TEXT NOT NULL, + sources TEXT, + updated_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS trades ( id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT NOT NULL, @@ -100,6 +164,77 @@ export function getScoreHistory() { return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all(); } +// ─── 個股 OHLCV 日線(長期累積,API 只補缺口)─── +const upsertBar = db.prepare(` + INSERT INTO price_bars (symbol, interval, date, open, high, low, close, volume, adjclose) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, interval, date) DO UPDATE SET + open=COALESCE(excluded.open, open), + high=COALESCE(excluded.high, high), + low=COALESCE(excluded.low, low), + close=COALESCE(excluded.close, close), + volume=COALESCE(excluded.volume, volume), + adjclose=COALESCE(excluded.adjclose, adjclose) +`); +function normBarPoint(p) { + const close = p.close != null ? Number(p.close) : null; + if (close == null || isNaN(close)) return null; + const adj = p.adjclose != null ? Number(p.adjclose) : close; + const o = p.open != null ? Number(p.open) : close; + const h = p.high != null ? Number(p.high) : close; + const l = p.low != null ? Number(p.low) : close; + const vol = p.volume != null ? Number(p.volume) : null; + return { date: p.date, open: o, high: h, low: l, close, volume: vol, adjclose: adj }; +} +export function upsertPriceBars(symbol, interval, points) { + if (!symbol || !points?.length) return 0; + let n = 0; + db.exec('BEGIN'); + try { + for (const raw of points) { + const p = normBarPoint(raw); + if (!p) continue; + upsertBar.run(symbol, interval, p.date, p.open, p.high, p.low, p.close, p.volume, p.adjclose); + n++; + } + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } + return n; +} +export function getPriceBars(symbol, interval = '1d', sinceISO = null) { + if (sinceISO) { + return db.prepare( + 'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? AND date>=? ORDER BY date ASC', + ).all(symbol, interval, sinceISO); + } + return db.prepare( + 'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? ORDER BY date ASC', + ).all(symbol, interval); +} +export function deletePriceBars(symbol, interval = '1d') { + db.prepare('DELETE FROM price_bars WHERE symbol=? AND interval=?').run(symbol, interval); +} +export function getPriceBarMeta(symbol, interval = '1d') { + const row = db.prepare( + 'SELECT COUNT(*) AS n, MIN(date) AS first_date, MAX(date) AS last_date FROM price_bars WHERE symbol=? AND interval=?', + ).get(symbol, interval); + return row || { n: 0, first_date: null, last_date: null }; +} +export function priceBarsToPoints(bars) { + return (bars || []).map(b => ({ + date: b.date, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + adjclose: b.adjclose ?? b.close, + })); +} + // ─── 通用 JSON 快取(給財報健檢等,沿用 cache 表,含 TTL)─── export function putCachedJSON(key, value) { db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)') @@ -118,6 +253,120 @@ export function getCachedEntry(key) { try { return { value: JSON.parse(row.payload), updatedAt: row.updated_at }; } catch { return null; } } +// ─── 價格走勢:自訂中文公司研究(管理層、新聞、簡介)─── +export function getCompanyIntelCustom(symbol) { + const row = db.prepare('SELECT payload, updated_at FROM company_intel_custom WHERE symbol = ?').get( + String(symbol || '').trim().toUpperCase(), + ); + if (!row) return null; + try { + return { data: JSON.parse(row.payload), updatedAt: row.updated_at }; + } catch { + return null; + } +} +export function saveCompanyIntelCustom(symbol, data) { + const sym = String(symbol || '').trim().toUpperCase(); + if (!sym) throw new Error('bad_symbol'); + db.prepare('INSERT OR REPLACE INTO company_intel_custom (symbol, payload, updated_at) VALUES (?, ?, ?)') + .run(sym, JSON.stringify(data || {}), Date.now()); + return { symbol: sym, updatedAt: Date.now() }; +} + +// ─── SEC 申報與財報/法說封存 ─── +const upsertFilingStmt = db.prepare(` + INSERT INTO sec_filings ( + symbol, accession, form, form_zh, filed_date, report_date, description, + primary_document, url, local_primary, local_txt, excerpt, is_earnings_related, + earnings_exhibits, archived_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, accession) DO UPDATE SET + form=excluded.form, form_zh=excluded.form_zh, filed_date=excluded.filed_date, + report_date=excluded.report_date, description=excluded.description, + primary_document=excluded.primary_document, url=excluded.url, + local_primary=COALESCE(excluded.local_primary, local_primary), + local_txt=COALESCE(excluded.local_txt, local_txt), + excerpt=COALESCE(excluded.excerpt, excerpt), + is_earnings_related=excluded.is_earnings_related, + earnings_exhibits=COALESCE(excluded.earnings_exhibits, earnings_exhibits), + archived_at=COALESCE(excluded.archived_at, archived_at) +`); +export function upsertSecFiling(row) { + const sym = String(row.symbol || '').trim().toUpperCase(); + upsertFilingStmt.run( + sym, row.accession, row.form || null, row.formZh || null, row.filedDate || null, + row.reportDate || null, row.description || null, row.primaryDocument || null, row.url || null, + row.localPrimary || null, row.localTxt || null, row.excerpt || null, + row.isEarningsRelated ? 1 : 0, row.earningsExhibits || null, Date.now(), + ); +} +export function listSecFilings(symbol) { + const sym = String(symbol || '').trim().toUpperCase(); + return db.prepare( + 'SELECT symbol, accession, form, form_zh AS formZh, filed_date AS filedDate, report_date AS reportDate, description, primary_document AS primaryDocument, url, local_primary AS localPrimary, local_txt AS localTxt, excerpt, is_earnings_related AS isEarningsRelated, earnings_exhibits AS earningsExhibits, archived_at AS archivedAt FROM sec_filings WHERE symbol=? ORDER BY filed_date DESC, accession DESC', + ).all(sym).map(r => ({ + ...r, + isEarningsRelated: !!r.isEarningsRelated, + earningsExhibits: r.earningsExhibits ? (() => { try { return JSON.parse(r.earningsExhibits); } catch { return null; } })() : null, + archivedAt: r.archivedAt ? new Date(r.archivedAt).toISOString() : null, + })); +} +const upsertEarnStmt = db.prepare(` + INSERT INTO earnings_events (symbol, event_date, title, title_zh, time_label, source, url, note, kind, accession, transcript_search_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, event_date, kind, accession, title) DO UPDATE SET + title_zh=excluded.title_zh, time_label=excluded.time_label, source=excluded.source, + url=COALESCE(excluded.url, url), note=excluded.note, transcript_search_url=excluded.transcript_search_url +`); +export function upsertEarningsEvent(row) { + const sym = String(row.symbol || '').trim().toUpperCase(); + upsertEarnStmt.run( + sym, row.eventDate || null, row.title || null, row.titleZh || row.title || null, + row.timeLabel || '', row.source || null, row.url || null, row.note || '', + row.kind || 'calendar', row.accession || '', row.transcriptSearchUrl || null, + ); +} +export function listEarningsEvents(symbol) { + const sym = String(symbol || '').trim().toUpperCase(); + return db.prepare( + 'SELECT id, symbol, event_date AS eventDate, title, title_zh AS titleZh, time_label AS timeLabel, source, url, note, kind, accession, transcript_search_url AS transcriptSearchUrl FROM earnings_events WHERE symbol=? ORDER BY event_date DESC, id DESC LIMIT 80', + ).all(sym); +} +export function getSecArchiveMeta(symbol) { + const sym = String(symbol || '').trim().toUpperCase(); + const row = db.prepare('SELECT payload, updated_at FROM sec_archive_meta WHERE symbol=?').get(sym); + if (!row) return null; + try { + const data = JSON.parse(row.payload); + return { ...data, lastSyncAt: data.lastSyncAt || row.updated_at }; + } catch { return { lastSyncAt: row.updated_at }; } +} +export function saveSecArchiveMeta(symbol, data) { + const sym = String(symbol || '').trim().toUpperCase(); + db.prepare('INSERT OR REPLACE INTO sec_archive_meta (symbol, payload, updated_at) VALUES (?, ?, ?)') + .run(sym, JSON.stringify(data || {}), Date.now()); +} + +export function getCompanyIntelEnriched(symbol) { + const sym = String(symbol || '').trim().toUpperCase(); + const row = db.prepare('SELECT payload, sources, updated_at FROM company_intel_enriched WHERE symbol=?').get(sym); + if (!row) return null; + try { + return { + data: JSON.parse(row.payload), + sources: row.sources ? JSON.parse(row.sources) : [], + updatedAt: row.updated_at, + }; + } catch { + return null; + } +} +export function saveCompanyIntelEnriched(symbol, data, sources = []) { + const sym = String(symbol || '').trim().toUpperCase(); + db.prepare('INSERT OR REPLACE INTO company_intel_enriched (symbol, payload, sources, updated_at) VALUES (?, ?, ?, ?)') + .run(sym, JSON.stringify(data || {}), JSON.stringify(sources || []), Date.now()); +} + // ─── 交易復盤 ─── const TRADE_FIELDS = ['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares', 'exit_date', 'exit_price', 'entry_reason', 'exit_reason', 'principle', 'mistake', 'mistake_note', 'note']; diff --git a/lib/fundamentals.js b/lib/fundamentals.js index 133878c..40a1ffa 100644 --- a/lib/fundamentals.js +++ b/lib/fundamentals.js @@ -196,10 +196,67 @@ async function yahooAuth() { _auth = { cookie, crumb, at: Date.now() }; return _auth; } +function mapEarningsTrendPeriod(t) { + if (!t) return null; + const ee = t.earningsEstimate || {}; + const rev = t.revenueEstimate || {}; + const ebitda = t.ebitdaEstimate || {}; + const growthPct = (g) => { + const v = num(g); + if (v == null) return null; + return Math.abs(v) <= 1.5 ? v * 100 : v; + }; + return { + period: t.period, + endDate: t.endDate || null, + epsAvg: num(ee.avg), + epsLow: num(ee.low), + epsHigh: num(ee.high), + epsAnalysts: num(ee.numberOfAnalysts), + epsGrowthPct: growthPct(ee.growth), + revenueAvg: num(rev.avg), + revenueLow: num(rev.low), + revenueHigh: num(rev.high), + revenueAnalysts: num(rev.numberOfAnalysts), + revenueGrowthPct: growthPct(rev.growth), + ebitdaAvg: num(ebitda.avg), + ebitdaLow: num(ebitda.low), + ebitdaHigh: num(ebitda.high), + ebitdaAnalysts: num(ebitda.numberOfAnalysts), + }; +} +function parseYahooEstimates(r) { + const trends = r.earningsTrend?.trend || []; + const byPeriod = {}; + for (const t of trends) if (t?.period) byPeriod[t.period] = t; + const fd = r.financialData || {}; + const sd = r.summaryDetail || {}; + const dks = r.defaultKeyStatistics || {}; + const currentYear = mapEarningsTrendPeriod(byPeriod['0y'] || byPeriod['+0y']); + const nextYear = mapEarningsTrendPeriod(byPeriod['+1y'] || byPeriod['1y']); + const hasConsensus = !!(currentYear?.epsAvg != null || currentYear?.revenueAvg != null + || nextYear?.epsAvg != null || nextYear?.revenueAvg != null); + return { + source: 'Yahoo Finance', + endpoint: 'quoteSummary modules: earningsTrend, financialData, defaultKeyStatistics', + fetchedAt: new Date().toISOString(), + forwardEps: num(dks.forwardEps) ?? num(fd.forwardEps), + currentYear, + nextYear, + currentQuarter: mapEarningsTrendPeriod(byPeriod['0q']), + nextQuarter: mapEarningsTrendPeriod(byPeriod['+1q'] || byPeriod['1q']), + targetMean: num(fd.targetMeanPrice) ?? num(sd.targetMeanPrice), + targetLow: num(fd.targetLowPrice) ?? num(sd.targetLowPrice), + targetHigh: num(fd.targetHighPrice) ?? num(sd.targetHighPrice), + targetAnalysts: num(fd.numberOfAnalystOpinions), + hasConsensus, + }; +} + async function fetchYahoo(symbol) { const { cookie, crumb } = await yahooAuth(); const mods = [ - 'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData', + 'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'earningsTrend', 'incomeStatementHistory', 'incomeStatementHistoryQuarterly', 'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly', 'balanceSheetHistoryQuarterly', @@ -265,6 +322,7 @@ async function fetchYahoo(symbol) { debtToAssets: pct(totalLiabilities, totalAssets), }; + const estimates = parseYahooEstimates(r); return { source: 'Yahoo Finance', name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol, @@ -273,6 +331,8 @@ async function fetchYahoo(symbol) { marketCap: num(r.price?.marketCap), sharesOutstanding: shares, quarters, annual, balance, + estimates, + targetPrice: estimates.targetMean ?? num(r.financialData?.targetMeanPrice) ?? null, }; } @@ -401,6 +461,7 @@ export async function getFundamentals(symbol) { if (!data) throw new Error('兩個來源都取不到財報(' + errs.join(';') + ')'); const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null; + const targetPrice = priceInfo.targetPrice ?? data.targetPrice ?? data.estimates?.targetMean ?? null; return { symbol, name: data.name || priceInfo.name || symbol, @@ -411,10 +472,19 @@ export async function getFundamentals(symbol) { peTrailing: priceInfo.peTrailing ?? data.peTrailing ?? 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, + targetPrice, + targetMeta: data.estimates ? { + mean: data.estimates.targetMean, + low: data.estimates.targetLow, + high: data.estimates.targetHigh, + analysts: data.estimates.targetAnalysts, + source: data.estimates.source, + endpoint: data.estimates.endpoint, + } : (priceInfo.targetPrice != null ? { source: priceInfo.source || 'Nasdaq summary', endpoint: 'api.nasdaq.com/.../summary' } : null), dividendYield: priceInfo.dividendYield ?? null, quarters: data.quarters || [], annual: data.annual || [], balance: data.balance || {}, + estimates: data.estimates || null, }; } diff --git a/lib/glossary.js b/lib/glossary.js index 9d58873..9deb66a 100644 --- a/lib/glossary.js +++ b/lib/glossary.js @@ -53,6 +53,11 @@ const TERM_TIPS = { what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。', how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。', }, + kdj: { + label: 'KDJ', + what: '隨機指標的變體:看收盤價在一段高低區間裡的位置,再平滑成 K、D,J=3K−2D。', + how: 'K、D 高於 80 常視為偏熱,低於 20 偏冷;J 線波動較大,適合搭配趨勢與成交量一起看(本頁為 9,3,3)。', + }, boll: { label: '布林通道', what: '在均線上下各畫一條「正常波動範圍」的界線,像橡皮筋包著股價。', @@ -130,23 +135,60 @@ const TERM_TIPS = { }, target_price: { label: '1 年目標價', - what: '券商分析師預測,這檔股票一年後「合理價位」大概在哪。', - how: '只是預測,常偏樂觀;當參考就好,不要當成一定會到的價格。', + what: '賣方/聚合分析師對未來約 12 個月的平均目標股價(共識),不是 MacroScope 自己算的。', + how: '只是預測,常偏樂觀;請對照卡片下方備註的資料來源與區間。', + formula: '顯示值 = 資料源提供的 targetMean(或 Nasdaq OneYrTarget)', + source: 'Yahoo financialData.targetMeanPrice;備援 Nasdaq summary OneYrTarget', + }, + est_revenue: { + label: '預估營收(共識)', + what: '多家分析師對某一財年或財季的營收平均預測(consensus avg)。', + how: '公布財報時常拿「實際 vs 這個 avg」比較 beat/miss。', + formula: '顯示值 = earningsTrend.revenueEstimate.avg(優先下一財年 +1y,否則本年 0y)', + source: 'Yahoo quoteSummary · module=earningsTrend(非官方 API,可能延遲)', + }, + est_eps: { + label: '預估 EPS(共識)', + what: '分析師對每股盈餘的平均預測;也可對照 forwardEps(通常為未來 12 個月)。', + how: 'EPS 預測會隨財報季更新;請看 ? 內的期間 endDate 與分析師人數。', + formula: '顯示值 = earningsTrend.earningsEstimate.avg;備註可附 defaultKeyStatistics.forwardEps', + source: 'Yahoo quoteSummary · earningsTrend + defaultKeyStatistics', + }, + est_ebitda: { + label: '預估 EBITDA(共識)', + what: '分析師對息稅折舊攤銷前獲利的共識預估(若 Yahoo 有提供該期 ebitdaEstimate)。', + how: '沒有共識時不顯示數字,避免用錯模型冒充分析師預測。', + formula: '顯示值 = earningsTrend.ebitdaEstimate.avg', + source: 'Yahoo quoteSummary · earningsTrend', + }, + growth_5y: { + label: '未來 5 年成長', + what: '可能是分析師對下一財年的營收/EPS 成長率,或本 App 用歷史營收算的 5 年 CAGR(會標明)。', + how: '兩者意義不同:共識 forward 只看一年左右;歷史 CAGR 只看過去。請以 ? 說明為準。', + formula: '共識:revenueEstimate.growth 或 earningsEstimate.growth\n歷史推算:CAGR = (Rev_end / Rev_start)^(1/年數) - 1', + source: '共識:Yahoo earningsTrend;歷史:MacroScope 用 annual 營收(來自 Yahoo/EDGAR)', }, dcf: { label: 'DCF 公允價值', - what: '把公司未來可能賺到的現金,一筆一筆折現加總,估算「現在值多少錢」。', - how: '假設(成長率、折現率)一改,結果差很多;適合看區間,不適合當精準股價。', + what: 'MacroScope 內建教學用 DCF:把未來 5 年自由現金流折現,加終值,調整現金與負債,再除以流通股數。', + how: '每檔股票的實際輸入數字與假設會寫在該卡片 ? 內;與分析師目標價無關。', + formula: 'FCF₀ = OCF + CapEx\nPV = Σ FCF_t/(1+r)^t + Terminal/(1+r)^5\n每股 = (PV + 現金 - 負債) / 流通股數', + source: '輸入:財報 OCF/CapEx/現金/負債(Yahoo 或 SEC EDGAR)', + model: '成長率由營收/淨利年增、毛利率、ROE 啟發式調整;折現率 8–13%;終值成長 2.5%', }, margin_of_safety: { label: '安全邊際', - what: '估算的合理價值,比現在股價高多少%。代表「便宜緩衝」有多大。', - how: '正值代表現價低於估算值;負值代表可能偏貴。留安全邊際是為了估錯還有退路。', + what: 'DCF 公允價值相對現價的溢價%;正值代表模型認為比現價便宜。', + how: 'DCF 假設一變,這個數字也會變;請搭配 DCF 卡片 ? 內公式核對。', + formula: '安全邊際(%) = (DCF 每股公允價值 / 現價 - 1) × 100', + source: 'MacroScope 本機 DCF 輸出 ÷ 即時現價(Yahoo/Nasdaq)', }, dcf_assumption: { label: '估值假設', - what: 'DCF 裡你猜的:未來幾年成長多快、折現率(要求報酬)多少、長期成長率多少。', - how: '假設越樂觀,算出來的價值越高;看這行是在提醒「這只是模型,不是真理」。', + what: 'DCF 模型當次計算使用的 5 年 FCF 成長率、折現率、終值成長率。', + how: '成長率會參考營收/淨利年增與 ROE/毛利率;波動與槓桿會調高折現率。', + formula: 'g = clamp(平均(營收年增, 淨利年增) + 品質加減分, -5%, 25%)\nr = 9% + 波動/槓桿調整(8–13%)\n終值 g = 2.5%', + source: 'MacroScope dcfValue();詳細數值見該次 DCF 卡片 ?', }, cagr: { label: 'CAGR 年化報酬', @@ -336,7 +378,7 @@ const TERM_TIPS = { section_technical: { label: '技術面', what: '用股價和成交量的圖表、指標,看短中期趨勢和熱度。', - how: '不能預測公司長期價值,但可幫你決定「現在進場會不會太追」。', + how: '專業軟體通常主圖看價+均線,副圖一次開 1~2 個(量、MACD、RSI、KDJ);本頁可開關副圖,避免畫面太擠。', }, section_risk: { label: '風險', @@ -355,8 +397,9 @@ const TERM_TIPS = { }, section_forecast: { label: '預測', - what: '分析師目標價、DCF 估算等「向前看」的數字,都是模型和猜測。', - how: '當參考區間,不要當成精準預言。', + what: '本區混合三類:① Yahoo 分析師共識 ② MacroScope 本機 DCF ③ 歷史 CAGR 推算。每一格旁邊 ? 會標公式與來源。', + how: '請以 ? 內「資料來源」「公式」核對;無法取得共識時會標示缺資料原因,不顯示假數字。', + caveat: 'Yahoo 為非官方 API;免費報價可能延遲。不構成投資建議。', }, section_robust: { label: '穩健度', @@ -375,10 +418,35 @@ function termTipHTML(t) { let html = `
    ${_esc(t.label)}
    `; html += `
    白話說${_esc(t.what)}
    `; if (t.how) html += `
    怎麼看${_esc(t.how)}
    `; + if (t.formula) html += `
    公式
    ${_esc(t.formula)}
    `; + if (t.model) html += `
    模型${_esc(t.model)}
    `; + if (t.source) html += `
    資料來源${_esc(t.source)}
    `; if (t.example) html += `
    舉例${_esc(t.example)}
    `; + if (t.caveat) html += `
    注意${_esc(t.caveat)}
    `; return html; } +let _metricTipSeq = 0; +function resetMetricTips() { + window.__METRIC_TIPS = {}; + _metricTipSeq = 0; +} +function registerMetricTip(detail) { + window.__METRIC_TIPS = window.__METRIC_TIPS || {}; + const id = 'mt' + (++_metricTipSeq); + window.__METRIC_TIPS[id] = detail; + return id; +} +function metricTipBtn(id, label) { + if (!id || !window.__METRIC_TIPS?.[id]) return ''; + const aria = label || window.__METRIC_TIPS[id].label || '說明'; + return ``; +} +function metricTipContent(id) { + const t = window.__METRIC_TIPS?.[id]; + return t ? termTipHTML(t) : ''; +} + function termTipContent(key) { const t = TERM_TIPS[key]; return t ? termTipHTML(t) : ''; @@ -387,7 +455,9 @@ function termTipContent(key) { function showTermTip(btn) { const el = document.getElementById('tooltip'); if (!el) return; - const html = termTipContent(btn.dataset.termKey); + const html = btn.dataset.metricTip + ? metricTipContent(btn.dataset.metricTip) + : termTipContent(btn.dataset.termKey); if (!html) return; el.innerHTML = html; el.classList.add('show'); @@ -395,10 +465,16 @@ function showTermTip(btn) { 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; + let top = r.top - th - 12; if (top < 10) top = r.bottom + 10; el.style.left = left + 'px'; el.style.top = top + 'px'; + el.style.transform = ''; + if (left <= 12) { + el.style.left = (r.right + 8) + 'px'; + el.style.top = Math.max(10, r.top + r.height / 2 - th / 2) + 'px'; + el.style.transform = 'none'; + } } function hideTermTip() { @@ -408,7 +484,7 @@ function hideTermTip() { function bindTermTips(root) { root = root || document; - root.querySelectorAll('.info-btn[data-term-key]').forEach(btn => { + root.querySelectorAll('.info-btn[data-term-key], .info-btn[data-metric-tip]').forEach(btn => { if (btn.dataset.termBound) return; btn.dataset.termBound = '1'; btn.addEventListener('mouseenter', () => showTermTip(btn)); diff --git a/lib/marketdata.js b/lib/marketdata.js index 4ac4320..b772e3b 100644 --- a/lib/marketdata.js +++ b/lib/marketdata.js @@ -50,8 +50,13 @@ async function fetchNasdaq(symbol, range, fromISO) { const chart = j?.data?.chart; if (!Array.isArray(chart) || chart.length < 2) continue; const points = chart - .map(c => ({ date: new Date(c.x).toISOString().slice(0, 10), close: c.y, adjclose: c.y })) - .filter(p => p.close != null); + .map(c => { + const y = c.y; + if (y == null) return null; + const date = new Date(c.x).toISOString().slice(0, 10); + return { date, open: y, high: y, low: y, close: y, adjclose: y, volume: c.volume ?? null }; + }) + .filter(Boolean); if (points.length >= 2) { return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' }; } @@ -63,14 +68,27 @@ 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 q = r.indicators?.quote?.[0] || {}; + const close = q.close || []; + const open = q.open || []; + const high = q.high || []; + const low = q.low || []; + const volume = q.volume || []; 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; // 跳過缺值(停牌/未成交) + 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 }); + points.push({ + date: new Date(ts[i] * 1000).toISOString().slice(0, 10), + open: open[i] != null ? open[i] : c, + high: high[i] != null ? high[i] : c, + low: low[i] != null ? low[i] : c, + close: c, + volume: volume[i] != null ? volume[i] : null, + adjclose: a, + }); } if (points.length < 1) throw new Error('歷史資料點過少'); return { @@ -102,14 +120,16 @@ async function fetchYahooHistory(symbol, range, interval, fromISO) { // 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] } export async function getHistory(symbol, range = '5y', interval = '1d') { - if (!RANGES.includes(range)) range = '5y'; + if (!RANGES.includes(range)) range = interval === '1d' ? 'max' : 'max'; if (!INTERVALS.includes(interval)) interval = '1d'; try { const hist = await fetchYahooHistory(symbol, range, interval, null); if (hist.points.length >= 2) return hist; } catch (e) { - const fallback = await fetchNasdaq(symbol, range).catch(() => null); - if (fallback) return fallback; + if (interval === '1d') { + const fallback = await fetchNasdaq(symbol, range).catch(() => null); + if (fallback) return fallback; + } throw e; } throw new Error('歷史資料點過少'); @@ -119,12 +139,16 @@ export async function getHistorySince(symbol, fromISO, range = 'max', interval = 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); + const padDays = interval === '1mo' ? 45 : interval === '1wk' ? 14 : 5; + const since = new Date(start.getTime() - padDays * 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; + } catch (e) { + if (interval === '1d') { + const fallback = await fetchNasdaq(symbol, range, since).catch(() => null); + if (fallback) return fallback; + } + throw e; } throw new Error('無法取得增量歷史股價'); } diff --git a/lib/news-text.js b/lib/news-text.js new file mode 100644 index 0000000..335742d --- /dev/null +++ b/lib/news-text.js @@ -0,0 +1,99 @@ +// Google RSS / 新聞欄位:HTML 實體解碼與摘要清理 + +export function decodeHtmlEntities(s) { + let t = String(s ?? ''); + if (!t) return ''; + t = t.replace(/&#x([0-9a-f]+);/gi, (_, hex) => { + const cp = parseInt(hex, 16); + return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : ''; + }); + t = t.replace(/&#(\d+);/g, (_, dec) => { + const cp = Number(dec); + return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : ''; + }); + const map = { + '<': '<', '>': '>', '&': '&', '"': '"', ''': "'", ''': "'", + ' ': ' ', ' ': ' ', + }; + for (const [ent, ch] of Object.entries(map)) { + if (t.includes(ent)) t = t.split(ent).join(ch); + } + return t; +} + +/** 解碼後移除標籤、壓縮空白 */ +export function cleanNewsPlain(s) { + const decoded = decodeHtmlEntities(s); + return decoded + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function looksLikeHtmlGarbage(s) { + const t = String(s || ''); + return /<|>|&#|href\s*=|target\s*=\s*["']?_blank/i.test(t) + || /^https?:\/\//i.test(t) + || t.length > 120 && /news\.google\.com/i.test(t); +} + +/** 從 Google RSS description 抽出摘要與媒體提示 */ +export function parseGoogleRssDescription(raw) { + const decoded = decodeHtmlEntities(raw); + const anchorText = cleanNewsPlain(decoded.match(/]*>([\s\S]*?)<\/a>/i)?.[1] || ''); + const fontPub = cleanNewsPlain(decoded.match(/]*>([\s\S]*?)<\/font>/i)?.[1] || ''); + return { anchorText, fontPub }; +} + +export function cleanGoogleNewsTitle(raw) { + let title = cleanNewsPlain(raw); + // 「標題 - 媒體名」尾綴 + title = title.replace(/\s*[-–—||]\s*[^-–—||]{1,48}$/, '').trim(); + return title; +} + +export function normalizeNewsItem(item = {}) { + const rawTitle = item.titleZh || item.title || ''; + const titleZh = cleanGoogleNewsTitle(rawTitle) || cleanNewsPlain(rawTitle) || '(無標題)'; + const titleEn = item.title && item.title !== rawTitle + ? cleanGoogleNewsTitle(item.title) + : (item.title && item.title !== titleZh ? cleanGoogleNewsTitle(item.title) : ''); + + const rawPublisher = item.publisher || ''; + let publisher = ''; + if (looksLikeHtmlGarbage(rawPublisher)) { + const { fontPub } = parseGoogleRssDescription(rawPublisher); + publisher = fontPub || ''; + } else { + publisher = cleanNewsPlain(rawPublisher); + } + if (!publisher || looksLikeHtmlGarbage(publisher)) { + const fromSource = cleanNewsPlain(item.source || ''); + publisher = fromSource && !looksLikeHtmlGarbage(fromSource) && !/Google\s*新聞/i.test(fromSource) + ? fromSource + : ''; + } + if (!publisher || looksLikeHtmlGarbage(publisher)) publisher = '新聞'; + + let description = cleanNewsPlain(item.descriptionZh || item.description || ''); + if (looksLikeHtmlGarbage(description)) description = ''; + if (description && (description === titleZh || description === rawTitle || titleZh.includes(description))) { + description = ''; + } + if (description && /news\.google\.com\/rss\/articles/i.test(description)) description = ''; + + return { + ...item, + title: titleEn || titleZh, + titleZh, + description: description.slice(0, 400), + descriptionZh: description.slice(0, 400), + publisher, + }; +} + +export function normalizeNewsList(list) { + return (list || []).map(normalizeNewsItem); +} \ No newline at end of file diff --git a/lib/price-store.js b/lib/price-store.js new file mode 100644 index 0000000..ee3a48d --- /dev/null +++ b/lib/price-store.js @@ -0,0 +1,315 @@ +// 個股 OHLCV:以 SQLite price_bars 為準,Yahoo/Nasdaq 只補「還沒有的」K 線 +import { + upsertPriceBars, getPriceBars, getPriceBarMeta, priceBarsToPoints, deletePriceBars, + getCachedEntry, putCachedJSON, +} from './db.js'; +import { getHistory, getHistorySince } from './marketdata.js'; + +const META_PREFIX = 'histmeta:'; + +function metaKey(symbol, interval) { + return `${META_PREFIX}${symbol}:${interval}`; +} + +function barPointCount(bars) { + return bars?.length || 0; +} + +/** 從舊版 JSON 快取(hist:SYM:range:1d)匯入 DB,避免重打 API */ +export function importLegacyHistCaches(symbol, interval = '1d') { + const keys = [ + `hist:${symbol}:max:${interval}`, + `hist:${symbol}:10y:${interval}`, + `hist:${symbol}:5y:${interval}`, + `hist:${symbol}:2y:${interval}`, + ]; + let best = null; + for (const key of keys) { + const entry = getCachedEntry(key); + const pts = entry?.value?.points; + if (!pts?.length) continue; + if (!best || pts.length > best.points.length) best = { key, entry, points: pts }; + } + if (!best) return 0; + const n = upsertPriceBars(symbol, interval, best.points); + const v = best.entry.value; + putCachedJSON(metaKey(symbol, interval), { + symbol: v.symbol || symbol, + name: v.name || null, + currency: v.currency || null, + source: v.source || 'legacy-cache', + interval, + _importedFrom: best.key, + _fetchedAt: best.entry.updatedAt || Date.now(), + }); + return n; +} + +function todayISO() { + return new Date().toISOString().slice(0, 10); +} + +function startOfWeekISO(dateStr) { + const d = new Date(dateStr + 'T12:00:00Z'); + const diff = (d.getUTCDay() + 6) % 7; + d.setUTCDate(d.getUTCDate() - diff); + return d.toISOString().slice(0, 10); +} + +/** 非即時研究:去掉可能尚未收盤的當根 K(日線≤昨日、周線≤上週、月線≤上月) */ +export function filterResearchBars(points, interval = '1d') { + if (!points?.length) return []; + const today = todayISO(); + if (interval === '1d') { + return points.filter(p => p.date < today); + } + const last = points[points.length - 1]; + if (interval === '1wk') { + if (startOfWeekISO(last.date) >= startOfWeekISO(today)) return points.slice(0, -1); + return points; + } + if (interval === '1mo') { + if (last.date.slice(0, 7) >= today.slice(0, 7)) return points.slice(0, -1); + return points; + } + return points; +} + +const TTL_BY_INTERVAL = { + '1d': 6 * 3600 * 1000, + '1wk': 24 * 3600 * 1000, + '1mo': 7 * 24 * 3600 * 1000, +}; + +function weekKey(dateStr) { + const d = new Date(dateStr + 'T12:00:00Z'); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const y = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const w = Math.ceil((((d - y) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(w).padStart(2, '0')}`; +} + +function resampleBars(dailyPoints, interval) { + const keyFn = interval === '1wk' + ? weekKey + : (dateStr) => dateStr.slice(0, 7); + const groups = new Map(); + for (const p of dailyPoints) { + const k = keyFn(p.date); + if (!groups.has(k)) groups.set(k, []); + groups.get(k).push(p); + } + const out = []; + for (const arr of [...groups.values()]) { + arr.sort((a, b) => (a.date < b.date ? -1 : 1)); + const last = arr[arr.length - 1]; + const highs = arr.map(x => x.high ?? x.close); + const lows = arr.map(x => x.low ?? x.close); + out.push({ + date: last.date, + open: arr[0].open ?? arr[0].close, + high: Math.max(...highs), + low: Math.min(...lows), + close: last.close, + volume: arr.some(x => x.volume != null) ? arr.reduce((s, x) => s + (x.volume || 0), 0) : null, + adjclose: last.adjclose ?? last.close, + }); + } + return out.sort((a, b) => (a.date < b.date ? -1 : 1)); +} + +async function fetchOrResample(symbol, interval) { + if (interval === '1d') return getHistory(symbol, 'max', '1d'); + try { + const hist = await getHistory(symbol, 'max', interval); + if (hist.points?.length >= 2 && barsMatchInterval( + hist.points.map(p => ({ date: p.date })), + interval, + )) return hist; + } catch (_) { /* Yahoo 429 等 → 改本機重採樣 */ } + await ensurePriceHistory(symbol, '1d', { fresh: false }); + const daily = priceBarsToPoints(getPriceBars(symbol, '1d')); + if (daily.length < 40) throw new Error('日線不足,無法合成周/月線'); + const points = resampleBars(daily, interval); + if (points.length < 2) throw new Error('重採樣後資料過少'); + return { + symbol, + name: null, + currency: null, + range: 'max', + interval, + source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線', + points, + }; +} + +/** 偵測 DB 是否誤存日線到周/月線 */ +function barsMatchInterval(bars, interval) { + if (!bars?.length || interval === '1d') return true; + if (interval === '1wk' && bars.length > 600) return false; + if (interval === '1mo' && bars.length > 200) return false; + if (bars.length < 3) return true; + const i = bars.length - 1; + const gap = (new Date(bars[i].date) - new Date(bars[i - 1].date)) / 86400000; + if (interval === '1wk') return gap >= 4; + if (interval === '1mo') return gap >= 20; + return true; +} + +function planFetch(bars, metaUpdatedAt, fresh, ttlMs, interval) { + if (!bars.length) return 'full'; + if (fresh) return 'incremental'; + const research = filterResearchBars(bars.map(b => ({ date: b.date })), interval); + const lastComplete = research.length ? research[research.length - 1].date : null; + const lastStored = bars[bars.length - 1].date; + const age = Date.now() - (metaUpdatedAt || 0); + if (lastComplete && lastStored > lastComplete && age > ttlMs / 2) return 'incremental'; + if (lastComplete && lastComplete < todayISO() && age > ttlMs) return 'incremental'; + if (age > ttlMs * 14) return 'incremental'; + return null; +} + +/** + * @returns {{ payload: object, cached: boolean, fetchMode: string|null }} + */ +export async function ensurePriceHistory(symbol, interval = '1d', { fresh = false, ttlMs } = {}) { + if (!ttlMs) ttlMs = TTL_BY_INTERVAL[interval] || TTL_BY_INTERVAL['1d']; + let bars = getPriceBars(symbol, interval); + if (!bars.length) importLegacyHistCaches(symbol, interval); + bars = getPriceBars(symbol, interval); + if (bars.length && !barsMatchInterval(bars, interval)) { + deletePriceBars(symbol, interval); + bars = []; + } + + const mk = metaKey(symbol, interval); + let metaEntry = getCachedEntry(mk); + let meta = metaEntry?.value || { symbol, interval }; + + const mode = planFetch(bars, metaEntry?.updatedAt, fresh, ttlMs, interval); + let fetchError = null; + + if (mode === 'full') { + try { + const hist = await fetchOrResample(symbol, interval); + upsertPriceBars(symbol, interval, hist.points); + meta = { + symbol: hist.symbol || symbol, + name: hist.name, + currency: hist.currency, + source: hist.source, + interval, + _fetchedAt: Date.now(), + _fetchMode: 'full', + }; + putCachedJSON(mk, meta); + bars = getPriceBars(symbol, interval); + } catch (e) { + fetchError = String(e?.message || e); + if (!bars.length) throw e; + } + } else if (mode === 'incremental') { + const lastDate = bars[bars.length - 1].date; + try { + let patch; + try { + patch = await getHistorySince(symbol, lastDate, 'max', interval); + if (interval !== '1d' && !barsMatchInterval(patch.points.map(p => ({ date: p.date })), interval)) { + throw new Error('patch_not_weekly'); + } + } catch { + const daily = priceBarsToPoints(getPriceBars(symbol, '1d')); + const resampled = resampleBars(daily, interval); + const idx = resampled.findIndex(p => p.date > lastDate); + patch = { + symbol, + interval, + points: idx >= 0 ? resampled.slice(idx) : [], + source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線', + }; + } + const added = upsertPriceBars(symbol, interval, patch.points); + meta = { + ...meta, + symbol: patch.symbol || symbol, + name: patch.name || meta.name, + currency: patch.currency || meta.currency, + source: patch.source || meta.source, + interval, + _fetchedAt: Date.now(), + _fetchMode: 'incremental', + _lastIncrementalAt: Date.now(), + _barsAdded: added, + }; + putCachedJSON(mk, meta); + bars = getPriceBars(symbol, interval); + } catch (e) { + fetchError = String(e?.message || e); + } + } + + const stat = getPriceBarMeta(symbol, interval); + const allPoints = priceBarsToPoints(bars); + const points = filterResearchBars(allPoints, interval); + const lastResearch = points.length ? points[points.length - 1].date : null; + return { + payload: { + symbol, + name: meta.name || null, + currency: meta.currency || null, + interval, + source: meta.source || 'MacroScope DB', + range: 'max', + points, + allBarsPoints: allPoints, + dbBars: stat.n, + researchBars: points.length, + firstDate: points[0]?.date || stat.first_date, + lastDate: lastResearch || stat.last_date, + researchThrough: lastResearch, + researchNote: interval === '1d' + ? '研究用日線截至昨日完整 K 線(今日未收盤不納入)' + : interval === '1wk' + ? '研究用周線截至上一根完整週 K' + : '研究用月線截至上一根完整月 K', + cached: mode == null, + fetchMode: mode, + fetchError, + }, + cached: mode == null, + fetchMode: mode, + }; +} + +/** 成交量圖:在研究用 K 線之外,盡量附上「當日」成交量(來自 DB 當根或即時報價) */ +export function buildVolumeSeries(researchPoints, allPoints, quote = {}, interval = '1d') { + if (interval !== '1d' || !researchPoints?.length) return researchPoints || []; + const today = todayISO(); + let todayVol = quote?.volume != null ? Number(quote.volume) : null; + const rawToday = (allPoints || []).find(p => p.date === today); + if (rawToday?.volume != null) todayVol = Number(rawToday.volume); + + const pts = researchPoints.map(p => ({ ...p })); + if (todayVol == null || isNaN(todayVol)) return pts; + + const last = pts[pts.length - 1]; + if (last?.date === today) { + pts[pts.length - 1] = { ...last, volume: todayVol }; + return pts; + } + + const px = quote?.price ?? quote?.regularMarketPrice ?? last?.close; + if (px == null) return pts; + pts.push({ + date: today, + open: quote?.previousClose ?? px, + high: quote?.dayHigh ?? px, + low: quote?.dayLow ?? px, + close: px, + volume: todayVol, + adjclose: px, + partialSession: true, + }); + return pts; +} \ No newline at end of file diff --git a/lib/sec-archive.js b/lib/sec-archive.js new file mode 100644 index 0000000..b62709b --- /dev/null +++ b/lib/sec-archive.js @@ -0,0 +1,433 @@ +// SEC 重要申報與財報/法說相關資料:抓取後寫入本機 archive/ + SQLite,避免連結失效 +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + listSecFilings, upsertSecFiling, listEarningsEvents, upsertEarningsEvent, + getSecArchiveMeta, saveSecArchiveMeta, +} from './db.js'; +import { fetchEarningsEvents } from './calendar.js'; +import { resolveInvestorRelationsUrl } from './companyintel-links.js'; +import { yahooQuoteSummary } from './yahoo-session.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ARCHIVE_ROOT = path.join(__dirname, '..', 'archive', 'sec'); +const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)'; +const MAX_FILINGS_SYNC = 36; +const MAX_FILE_BYTES = 12 * 1024 * 1024; +const EXCERPT_LEN = 4000; + +const IMPORTANT_FORMS = new Set([ + '10-K', '10-Q', '8-K', '6-K', '20-F', 'DEF 14A', 'DEFA14A', 'S-1', 'S-3', 'F-1', 'F-3', + '424B1', '424B2', '424B3', '424B4', '424B5', 'SC 13D', 'SC 13G', '4', '3', '5', +]); + +const FORM_ZH = { + '10-K': '年報', '10-Q': '季報', '8-K': '重大事件(含財報公告)', '6-K': '外國公司重大事件', + '20-F': '外國公司年報', 'DEF 14A': '股東會說明書', 'DEFA14A': '股東會補充', 'S-1': '上市/增資說明', + 'S-3': '增資說明', 'F-1': '外國公司上市', 'F-3': '外國公司增資', '4': '內部人交易', + '3': '內部人持股', '5': '內部人年度', 'SC 13D': '主動持股申報', 'SC 13G': '被動持股申報', +}; + +function formLabelZh(form) { + const base = String(form || '').replace(/\/A$/i, ''); + return FORM_ZH[base] || FORM_ZH[form] || form; +} + +function isImportantForm(form) { + const f = String(form || '').trim(); + if (!f) return false; + const base = f.replace(/\/A$/i, ''); + if (IMPORTANT_FORMS.has(base) || IMPORTANT_FORMS.has(f)) return true; + if (/^424B/i.test(f)) return true; + return false; +} + +let _tickerMap = null; +async function tickerToCik(symbol) { + if (!_tickerMap) { + const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: { 'User-Agent': SEC_UA } }); + if (!res.ok) throw new Error(`SEC tickers HTTP ${res.status}`); + const d = await res.json(); + _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; +} + +async function text(url, ms = 20000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { headers: { 'User-Agent': SEC_UA }, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.text(); + } finally { clearTimeout(timer); } +} + +async function json(url, ms = 15000) { + const res = await fetch(url, { headers: { 'User-Agent': SEC_UA, Accept: 'application/json' }, signal: AbortSignal.timeout(ms) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +function accNoDash(accn) { + return String(accn || '').replace(/-/g, ''); +} + +function filingDir(symbol, accn) { + return path.join(ARCHIVE_ROOT, symbol, accNoDash(accn)); +} + +function edgarPrimaryUrl(cikNum, accn, primary) { + return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${primary}`; +} + +function edgarTxtUrl(cikNum, accn) { + return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${accn}.txt`; +} + +function edgarIndexJsonUrl(cikNum, accn) { + return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${accn}-index.json`; +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeIfAbsent(filePath, content) { + if (fs.existsSync(filePath)) return false; + fs.writeFileSync(filePath, content); + return true; +} + +function excerptFromHtml(html) { + const plain = String(html || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&#\d+;/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return plain.slice(0, EXCERPT_LEN); +} + +function isEarnings8k(txt, description) { + const blob = `${description || ''}\n${txt || ''}`.slice(0, 120000); + return /Item\s+2\.02/i.test(blob) || /Results of Operations and Financial Condition/i.test(blob) + || /財報|earnings release|quarterly results/i.test(blob); +} + +function collectFilingsFromSubmissions(sub, symbol) { + const f = sub.filings?.recent || {}; + const out = []; + const forms = f.form || []; + for (let i = 0; i < forms.length && out.length < MAX_FILINGS_SYNC * 2; i++) { + const form = forms[i]; + if (!isImportantForm(form)) continue; + const accn = f.accessionNumber[i]; + if (!accn) continue; + out.push({ + symbol, + accession: accn, + form, + formZh: formLabelZh(form), + filedDate: f.filingDate[i] || null, + reportDate: f.reportDate?.[i] || null, + primaryDocument: f.primaryDocument?.[i] || null, + description: f.primaryDocDescription?.[i] || f.description?.[i] || '', + isEarningsRelated: form.replace(/\/A$/i, '') === '8-K', + }); + if (out.length >= MAX_FILINGS_SYNC) break; + } + return out; +} + +async function downloadToFile(url, destPath) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 45000); + try { + const res = await fetch(url, { headers: { 'User-Agent': SEC_UA }, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > MAX_FILE_BYTES) return { skipped: true, reason: 'too_large', size: buf.length }; + ensureDir(path.dirname(destPath)); + fs.writeFileSync(destPath, buf); + return { skipped: false, size: buf.length }; + } finally { clearTimeout(timer); } +} + +async function archiveFiling(meta, cikNum) { + const { symbol, accession, primaryDocument } = meta; + const dir = filingDir(symbol, accession); + const metaPath = path.join(dir, 'meta.json'); + if (fs.existsSync(metaPath)) { + try { + const prev = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (prev.localPrimary) { + return { ...meta, localPrimary: prev.localPrimary, localTxt: prev.localTxt || null, excerpt: prev.excerpt || null, archived: true, reused: true }; + } + } catch { /* re-download */ } + } + + ensureDir(dir); + const files = { localPrimary: null, localTxt: null, excerpt: null, archived: false, reused: false }; + const primary = primaryDocument || `${accession}.txt`; + + if (primary && !primary.endsWith('.txt')) { + const url = edgarPrimaryUrl(cikNum, accession, primary); + const ext = path.extname(primary) || '.htm'; + const dest = path.join(dir, `primary${ext}`); + try { + const r = await downloadToFile(url, dest); + if (!r.skipped) { + files.localPrimary = path.relative(path.join(__dirname, '..'), dest); + const html = fs.readFileSync(dest, 'utf8'); + files.excerpt = excerptFromHtml(html); + files.archived = true; + } + } catch { /* metadata only */ } + } + + const txtUrl = edgarTxtUrl(cikNum, accession); + const txtDest = path.join(dir, 'filing.txt'); + try { + const r = await downloadToFile(txtUrl, txtDest); + if (!r.skipped) { + files.localTxt = path.relative(path.join(__dirname, '..'), txtDest); + files.archived = true; + if (!files.excerpt) { + const raw = fs.readFileSync(txtDest, 'utf8').slice(0, 80000); + files.excerpt = excerptFromHtml(raw); + } + } + } catch { /* ok */ } + + const earningsExhibits = []; + if (meta.isEarningsRelated) { + try { + const idx = await json(edgarIndexJsonUrl(cikNum, accession)); + const items = idx.directory?.item || []; + for (const it of items) { + const name = String(it.name || ''); + const desc = String(it.description || ''); + if (!/ex-99|press release|earnings/i.test(name + desc)) continue; + if (!/\.htm|\.html|\.txt$/i.test(name)) continue; + const exUrl = edgarPrimaryUrl(cikNum, accession, name); + const exDest = path.join(dir, name.replace(/[^\w.\-]+/g, '_')); + try { + const r = await downloadToFile(exUrl, exDest); + if (!r.skipped) { + earningsExhibits.push({ + name, + description: desc, + localPath: path.relative(path.join(__dirname, '..'), exDest), + url: exUrl, + }); + } + } catch { /* skip exhibit */ } + } + } catch { /* no index */ } + } + + const fullMeta = { + ...meta, + ...files, + earningsExhibits, + edgarUrl: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${cikNum}&type=${encodeURIComponent(meta.form)}&dateb=&owner=include&count=40`, + archivedAt: new Date().toISOString(), + }; + fs.writeFileSync(metaPath, JSON.stringify(fullMeta, null, 2)); + return fullMeta; +} + +async function syncEarningsCalendar(symbol) { + const today = new Date(); + const start = new Date(today); + start.setUTCDate(start.getUTCDate() - 400); + const end = new Date(today); + end.setUTCDate(end.getUTCDate() + 120); + const startISO = start.toISOString().slice(0, 10); + const endISO = end.toISOString().slice(0, 10); + const events = await fetchEarningsEvents(startISO, endISO, [symbol]); + let n = 0; + for (const ev of events) { + upsertEarningsEvent({ + symbol, + eventDate: ev.date, + title: ev.title, + titleZh: ev.title, + timeLabel: ev.time || '', + source: ev.source || 'Nasdaq earnings', + url: ev.url, + note: ev.note || '', + kind: 'earnings_calendar', + }); + n++; + } + return n; +} + +export async function syncSecArchive(symbol, { force = false } = {}) { + symbol = String(symbol || '').trim().toUpperCase(); + if (!symbol) throw new Error('bad_symbol'); + const hit = await tickerToCik(symbol); + if (!hit) throw new Error('cik_not_found'); + + const meta0 = getSecArchiveMeta(symbol); + const softMs = (Number(process.env.SEC_ARCHIVE_SOFT_HOURS) || 12) * 3600 * 1000; + if (!force && meta0?.lastSyncAt && Date.now() - meta0.lastSyncAt < softMs) { + return { + symbol, + skipped: true, + filings: listSecFilings(symbol), + earnings: listEarningsEvents(symbol), + meta: meta0, + }; + } + + const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`); + const cikNum = Number(hit.cik); + let investorUrl = null; + try { + const y = await yahooQuoteSummary(symbol, 'assetProfile'); + investorUrl = resolveInvestorRelationsUrl(y?.assetProfile?.website)?.url || null; + } catch { /* */ } + const candidates = collectFilingsFromSubmissions(sub, symbol); + const synced = []; + let downloaded = 0; + + for (const row of candidates) { + let archived = null; + try { + archived = await archiveFiling({ ...row, cik: hit.cik, companyName: hit.name }, cikNum); + if (archived.archived && !archived.reused) downloaded++; + } catch { + archived = { ...row, archived: false }; + } + + let earningsFlag = row.isEarningsRelated; + let excerpt = archived?.excerpt || null; + if (earningsFlag && archived?.localTxt) { + try { + const txt = fs.readFileSync(path.join(__dirname, '..', archived.localTxt), 'utf8'); + earningsFlag = isEarnings8k(txt, row.description); + if (earningsFlag && !excerpt) excerpt = excerptFromHtml(txt); + } catch { /* */ } + } else if (archived?.localTxt) { + try { + const txt = fs.readFileSync(path.join(__dirname, '..', archived.localTxt), 'utf8').slice(0, 50000); + if (isEarnings8k(txt, row.description)) earningsFlag = true; + } catch { /* */ } + } + + upsertSecFiling({ + symbol, + accession: row.accession, + form: row.form, + formZh: row.formZh, + filedDate: row.filedDate, + reportDate: row.reportDate, + description: row.description, + primaryDocument: row.primaryDocument, + url: row.primaryDocument + ? edgarPrimaryUrl(cikNum, row.accession, row.primaryDocument) + : edgarTxtUrl(cikNum, row.accession), + localPrimary: archived?.localPrimary || null, + localTxt: archived?.localTxt || null, + excerpt, + isEarningsRelated: earningsFlag ? 1 : 0, + earningsExhibits: archived?.earningsExhibits ? JSON.stringify(archived.earningsExhibits) : null, + }); + + if (earningsFlag) { + upsertEarningsEvent({ + symbol, + eventDate: row.reportDate || row.filedDate, + title: `${symbol} 財報/重大事件 8-K`, + titleZh: `${symbol} 財報公告(8-K Item 2.02)`, + timeLabel: '', + source: 'SEC 8-K', + url: archived?.localPrimary + ? null + : (row.primaryDocument ? edgarPrimaryUrl(cikNum, row.accession, row.primaryDocument) : edgarTxtUrl(cikNum, row.accession)), + note: row.description || '已封存申報全文;法說逐字稿多由公司投資人關係頁發布', + kind: 'sec_8k', + accession: row.accession, + transcriptSearchUrl: investorUrl, + }); + } + + synced.push({ + ...row, + archived: !!archived?.archived, + localPrimary: archived?.localPrimary, + isEarningsRelated: earningsFlag, + }); + } + + const earnN = await syncEarningsCalendar(symbol).catch(() => 0); + + const meta = { + symbol, + companyName: hit.name, + cik: hit.cik, + lastSyncAt: Date.now(), + filingCount: listSecFilings(symbol).length, + earningsCount: listEarningsEvents(symbol).length, + downloadedThisRun: downloaded, + earningsCalendarSynced: earnN, + }; + saveSecArchiveMeta(symbol, meta); + + return { + symbol, + skipped: false, + filings: listSecFilings(symbol), + earnings: listEarningsEvents(symbol), + meta, + synced, + }; +} + +export function getSecArchivePayload(symbol) { + symbol = String(symbol || '').trim().toUpperCase(); + return { + symbol, + filings: listSecFilings(symbol), + earnings: listEarningsEvents(symbol), + meta: getSecArchiveMeta(symbol), + }; +} + +export function resolveArchiveFile(symbol, accession, file) { + symbol = String(symbol || '').trim().toUpperCase(); + const dir = filingDir(symbol, accession); + if (!fs.existsSync(dir)) return null; + const safe = path.basename(String(file || 'primary.htm')); + const full = path.join(dir, safe); + if (!full.startsWith(dir)) return null; + if (!fs.existsSync(full)) { + const metaPath = path.join(dir, 'meta.json'); + if (fs.existsSync(metaPath)) { + try { + const m = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (m.localPrimary) { + const p = path.join(__dirname, '..', m.localPrimary); + if (fs.existsSync(p)) return p; + } + if (m.localTxt) { + const p = path.join(__dirname, '..', m.localTxt); + if (fs.existsSync(p)) return p; + } + } catch { /* */ } + } + return null; + } + return full; +} \ No newline at end of file diff --git a/lib/sector-flow.js b/lib/sector-flow.js new file mode 100644 index 0000000..d17cb16 --- /dev/null +++ b/lib/sector-flow.js @@ -0,0 +1,372 @@ +// 美股 11 大板塊(SPDR 行業 ETF)— 熱力圖、輪動、資金流向、ETF 規模(機構被動配置 proxy) +import { getHistory } from './marketdata.js'; +import { yahooQuoteSummary, resetYahooAuth, sleep as yahooSleep } from './yahoo-session.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'; + +export const SECTOR_ETFS = [ + { etf: 'XLK', nameZh: '科技', nameEn: 'Technology', group: 'growth' }, + { etf: 'XLC', nameZh: '通訊服務', nameEn: 'Communication', group: 'growth' }, + { etf: 'XLY', nameZh: '非必需消費', nameEn: 'Cons. Discretionary', group: 'cyclical' }, + { etf: 'XLP', nameZh: '必需消費', nameEn: 'Cons. Staples', group: 'defensive' }, + { etf: 'XLE', nameZh: '能源', nameEn: 'Energy', group: 'cyclical' }, + { etf: 'XLF', nameZh: '金融', nameEn: 'Financials', group: 'cyclical' }, + { etf: 'XLV', nameZh: '醫療保健', nameEn: 'Health Care', group: 'defensive' }, + { etf: 'XLI', nameZh: '工業', nameEn: 'Industrials', group: 'cyclical' }, + { etf: 'XLB', nameZh: '原物料', nameEn: 'Materials', group: 'cyclical' }, + { etf: 'XLRE', nameZh: '房地產', nameEn: 'Real Estate', group: 'rate_sensitive' }, + { etf: 'XLU', nameZh: '公用事業', nameEn: 'Utilities', group: 'defensive' }, +]; + +const BENCHMARK = 'SPY'; + +/** Yahoo 限流時的示意持股(僅 SPY;板塊 ETF 仍嘗試即時抓取) */ +const TOP_HOLDINGS_FALLBACK = { + SPY: [ + { symbol: 'NVDA', name: 'NVIDIA Corp', pct: 7.85, pctFmt: '7.85%' }, + { symbol: 'AAPL', name: 'Apple Inc', pct: 6.45, pctFmt: '6.45%' }, + { symbol: 'MSFT', name: 'Microsoft Corp', pct: 4.9, pctFmt: '4.90%' }, + { symbol: 'AMZN', name: 'Amazon.com Inc', pct: 3.8, pctFmt: '3.80%' }, + { symbol: 'META', name: 'Meta Platforms Inc', pct: 3.2, pctFmt: '3.20%' }, + { symbol: 'GOOGL', name: 'Alphabet Inc Class A', pct: 2.9, pctFmt: '2.90%' }, + { symbol: 'GOOG', name: 'Alphabet Inc Class C', pct: 2.5, pctFmt: '2.50%' }, + { symbol: 'BRK-B', name: 'Berkshire Hathaway Inc Class B', pct: 2.4, pctFmt: '2.40%' }, + { symbol: 'AVGO', name: 'Broadcom Inc', pct: 2.3, pctFmt: '2.30%' }, + { symbol: 'TSLA', name: 'Tesla Inc', pct: 2.1, pctFmt: '2.10%' }, + ], +}; + +function closeAt(points, idx) { + if (!points?.length) return null; + const i = idx < 0 ? points.length + idx : idx; + const p = points[i]; + if (!p) return null; + return p.adjclose ?? p.close; +} + +function returnOver(points, days) { + if (!points?.length || points.length <= days) return null; + const last = closeAt(points, -1); + const prev = closeAt(points, -1 - days); + if (last == null || prev == null || !prev) return null; + return ((last / prev) - 1) * 100; +} + +function avgVolume(points, days) { + const slice = points.slice(-days).map(p => p.volume).filter(v => v != null && v > 0); + if (!slice.length) return null; + return slice.reduce((a, b) => a + b, 0) / slice.length; +} + +async function fetchEtfTopHoldings(symbols, limit = 10) { + const out = {}; + for (const sym of symbols) { + try { + const r = await yahooQuoteSummary(sym, 'topHoldings'); + const list = r?.topHoldings?.holdings || []; + out[sym] = list.slice(0, limit).map(h => ({ + symbol: (h.symbol || '').toUpperCase(), + name: h.holdingName || h.symbol, + pct: h.holdingPercent?.raw != null ? h.holdingPercent.raw * 100 : null, + pctFmt: h.holdingPercent?.fmt || null, + })).filter(h => h.symbol); + } catch { /* skip */ } + await new Promise(r => setTimeout(r, 120)); + } + return out; +} + +function mergeHoldingsWithFallback(fetched, symbols) { + const out = { ...fetched }; + let usedFallback = false; + for (const sym of symbols) { + if (out[sym]?.length) continue; + if (TOP_HOLDINGS_FALLBACK[sym]) { + out[sym] = TOP_HOLDINGS_FALLBACK[sym]; + usedFallback = true; + } + } + return { out, usedFallback }; +} + +async function fetchEtfAum(symbols) { + const out = {}; + for (const sym of symbols) { + try { + const r = await yahooQuoteSummary(sym, 'defaultKeyStatistics'); + const raw = r?.defaultKeyStatistics?.totalAssets?.raw; + if (raw != null) out[sym] = raw; + } catch { /* skip symbol */ } + await new Promise(r => setTimeout(r, 100)); + } + return out; +} + +function rotationQuadrant(rs20, momentum) { + // 類 RRG:X=相對大盤強度,Y=短期動能(5日減20日) + if (rs20 >= 0 && momentum >= 0) return { key: 'leading', labelZh: '領漲', tone: 'good' }; + if (rs20 >= 0 && momentum < 0) return { key: 'weakening', labelZh: '轉弱', tone: 'warn' }; + if (rs20 < 0 && momentum >= 0) return { key: 'improving', labelZh: '改善', tone: 'good' }; + return { key: 'lagging', labelZh: '落後', tone: 'bad' }; +} + +function buildSectorRow(meta, points, spyPoints, aum) { + const ret1d = returnOver(points, 1); + const ret5d = returnOver(points, 5); + const ret20d = returnOver(points, 20); + const ret60d = returnOver(points, 60); + const spy20 = returnOver(spyPoints, 20); + const spy5 = returnOver(spyPoints, 5); + const rs20 = ret20d != null && spy20 != null ? ret20d - spy20 : null; + const rs5 = ret5d != null && spy5 != null ? ret5d - spy5 : null; + const momentum = rs5 != null && rs20 != null ? rs5 - rs20 : null; + const volToday = points.at(-1)?.volume; + const avgVol = avgVolume(points, 20); + const volRatio = volToday && avgVol ? volToday / avgVol : null; + const flowScore = volRatio != null && ret5d != null + ? volRatio * (ret5d >= 0 ? 1 : -1) * Math.min(Math.abs(ret5d), 8) + : null; + const quad = rs20 != null && momentum != null ? rotationQuadrant(rs20, momentum) : null; + const price = closeAt(points, -1); + return { + ...meta, + price, + ret1d, ret5d, ret20d, ret60d, + rs20, rs5, momentum, + volRatio, + flowScore, + aum, + aumB: aum != null ? aum / 1e9 : null, + quadrant: quad, + }; +} + +function summarizeRotation(rows) { + const ranked = [...rows].filter(r => r.rs20 != null).sort((a, b) => b.rs20 - a.rs20); + const leader = ranked[0]; + const laggard = ranked[ranked.length - 1]; + const byQuad = { leading: [], weakening: [], improving: [], lagging: [] }; + for (const r of rows) { + if (r.quadrant?.key) byQuad[r.quadrant.key].push(r.etf); + } + const cyclicalAvg = avgOf(rows.filter(r => r.group === 'cyclical' || r.group === 'growth'), 'rs20'); + const defensiveAvg = avgOf(rows.filter(r => r.group === 'defensive' || r.group === 'rate_sensitive'), 'rs20'); + let regime = '均衡輪動'; + let regimeNote = '景氣敏感與防禦板塊表現接近,資金未明顯單邊押注。'; + if (cyclicalAvg != null && defensiveAvg != null) { + const spread = cyclicalAvg - defensiveAvg; + if (spread > 1.5) { + regime = '偏景氣/成長'; + regimeNote = `循環型板塊 20 日相對強度平均較防禦型高 ${spread.toFixed(1)} 個百分點,資金偏向風險與景氣復甦敘事。`; + } else if (spread < -1.5) { + regime = '偏防禦'; + regimeNote = `防禦型板塊相對較強(差距約 ${Math.abs(spread).toFixed(1)} pct),市場偏避險或降風險偏好。`; + } + } + return { + leader: leader ? { etf: leader.etf, nameZh: leader.nameZh, rs20: leader.rs20 } : null, + laggard: laggard ? { etf: laggard.etf, nameZh: laggard.nameZh, rs20: laggard.rs20 } : null, + ranked: ranked.map(r => ({ etf: r.etf, nameZh: r.nameZh, rs20: r.rs20, quadrant: r.quadrant?.labelZh })), + byQuadrant: byQuad, + regime, + regimeNote, + cyclicalAvg, + defensiveAvg, + }; +} + +function avgOf(rows, field) { + const vals = rows.map(r => r[field]).filter(v => v != null); + if (!vals.length) return null; + return vals.reduce((a, b) => a + b, 0) / vals.length; +} + +function institutionalView(rows) { + let withAum = rows.filter(r => r.aumB != null).sort((a, b) => b.aumB - a.aumB); + let aumProxy = false; + if (!withAum.length) { + aumProxy = true; + withAum = [...rows] + .filter(r => r.price != null) + .sort((a, b) => (b.flowScore || 0) - (a.flowScore || 0)) + .map((r, i, arr) => { + const w = Math.max(1, Math.abs(r.flowScore || 0) + Math.abs(r.rs20 || 0) + 1); + return { etf: r.etf, nameZh: r.nameZh, aumB: w, sharePct: null, _rank: i }; + }); + const sum = withAum.reduce((s, r) => s + r.aumB, 0); + withAum = withAum.map(r => ({ ...r, sharePct: sum ? (r.aumB / sum) * 100 : null })); + } + const totalAum = withAum.reduce((s, r) => s + (r.aumB || 0), 0); + const byFlow = [...rows].filter(r => r.flowScore != null).sort((a, b) => b.flowScore - a.flowScore); + return { + totalAumB: totalAum || null, + aumProxy, + byAum: withAum.map(r => ({ + etf: r.etf, + nameZh: r.nameZh, + aumB: r.aumB, + sharePct: r.sharePct ?? (totalAum ? (r.aumB / totalAum) * 100 : null), + })), + flowLeaders: byFlow.slice(0, 5).map(r => ({ + etf: r.etf, + nameZh: r.nameZh, + flowScore: r.flowScore, + ret5d: r.ret5d, + volRatio: r.volRatio, + note: flowNote(r), + })), + flowLaggards: byFlow.slice(-3).reverse().map(r => ({ + etf: r.etf, + nameZh: r.nameZh, + flowScore: r.flowScore, + ret5d: r.ret5d, + volRatio: r.volRatio, + note: flowNote(r), + })), + disclaimer: aumProxy + ? 'ETF 總資產暫時無法連線取得,下表改以「流向動能分數」相對占比示意機構資金關注度(非實際 AUM)。流向分數=量能異常×5日報酬方向。' + : 'ETF 總資產為被動/機構配置規模 proxy(Yahoo totalAssets);流向分數結合近 5 日報酬與成交量異常,非官方申報流向。', + }; +} + +function flowNote(r) { + const parts = []; + if (r.ret5d != null) parts.push(`5日 ${r.ret5d >= 0 ? '+' : ''}${r.ret5d.toFixed(1)}%`); + if (r.volRatio != null) parts.push(`量能 ${r.volRatio.toFixed(2)}× 均量`); + return parts.join(' · '); +} + +function buildStockExposure(holdingsByEtf, rotation, rows) { + const packs = []; + const spy = holdingsByEtf[BENCHMARK]; + if (spy?.length) { + packs.push({ + etf: BENCHMARK, + nameZh: 'S&P 500 大盤', + reason: '指數與被動基金的核心配置,代表整體機構底倉。', + holdings: spy, + }); + } + const ranked = (rotation?.ranked || []).slice(0, 3); + for (const r of ranked) { + const list = holdingsByEtf[r.etf]; + if (!list?.length) continue; + const meta = rows.find(x => x.etf === r.etf); + packs.push({ + etf: r.etf, + nameZh: meta?.nameZh || r.nameZh, + reason: `20 日相對大盤 RS ${r.rs20 != null ? (r.rs20 >= 0 ? '+' : '') + r.rs20.toFixed(1) + '%' : '—'},資金輪動偏強的板塊。`, + holdings: list, + }); + } + const composite = {}; + for (const p of packs) { + const w = p.etf === BENCHMARK ? 1 : 0.65; + for (const h of p.holdings) { + if (!composite[h.symbol]) composite[h.symbol] = { symbol: h.symbol, name: h.name, score: 0, refs: [] }; + composite[h.symbol].score += (h.pct || 0) * w; + composite[h.symbol].refs.push(p.etf); + } + } + const topStocks = Object.values(composite) + .sort((a, b) => b.score - a.score) + .slice(0, 12) + .map(s => ({ ...s, refs: [...new Set(s.refs)] })); + return { + packs, + topStocks, + howToRead: '下方為各 ETF 最新公布的前十大持股(非即時買賣紀錄)。機構「買什麼」在實務上常透過 ETF 與指數基金間接持有;要看單一對沖基金最新建倉,需查 13F(季報、約延遲 45 天)。', + disclaimer: '持股來自 Yahoo ETF topHoldings,更新頻率通常為每月;與 13F、Dark pool 即時流向不同。', + usedFallback: false, + }; +} + +export async function buildSectorFlowPayload() { + const symbols = [...SECTOR_ETFS.map(s => s.etf), BENCHMARK]; + + // 最先抓大盤持股(避免後續 Yahoo 請求過多被限流) + let earlyHoldings = {}; + try { + resetYahooAuth(); + earlyHoldings = await fetchEtfTopHoldings([BENCHMARK], 10); + } catch { /* optional */ } + + const histories = await Promise.all(symbols.map(async sym => { + try { + const h = await getHistory(sym, '6mo', '1d'); + return [sym, h.points]; + } catch { + return [sym, null]; + } + })); + const pts = Object.fromEntries(histories); + const spyPoints = pts[BENCHMARK]; + if (!spyPoints?.length) throw new Error('無法取得 SPY 基準'); + + // 先算輪動,優先抓 ETF 持股(僅 4 檔、避免在 11 次 AUM 之後被 Yahoo 限流) + const prelimRows = SECTOR_ETFS.map(meta => { + const points = pts[meta.etf]; + if (!points?.length) return { ...meta, error: 'no_data' }; + return buildSectorRow(meta, points, spyPoints, null); + }).filter(Boolean); + const rotationPre = summarizeRotation(prelimRows.filter(s => !s.error)); + const holdingEtfs = [BENCHMARK, ...(rotationPre.ranked || []).slice(0, 3).map(r => r.etf)]; + const uniqueHoldEtfs = [...new Set(holdingEtfs)]; + let stockExposure = null; + let holdingsUsedFallback = false; + try { + let holdingsByEtf = { ...earlyHoldings }; + const needFetch = uniqueHoldEtfs.filter(s => !holdingsByEtf[s]); + if (needFetch.length) { + await yahooSleep(300); + const more = await fetchEtfTopHoldings(needFetch, 10); + holdingsByEtf = { ...holdingsByEtf, ...more }; + } + const merged = mergeHoldingsWithFallback(holdingsByEtf, uniqueHoldEtfs); + holdingsByEtf = merged.out; + holdingsUsedFallback = merged.usedFallback; + if (Object.keys(holdingsByEtf).length) { + stockExposure = buildStockExposure(holdingsByEtf, rotationPre, prelimRows.filter(s => !s.error)); + if (stockExposure && holdingsUsedFallback) { + stockExposure.disclaimer += ' SPY 持股在資料源限流時暫用近期常見權重示意。'; + stockExposure.usedFallback = true; + } + } + } catch (err) { + console.warn('[sector-flow] topHoldings:', err?.message || err); + } + + let aumMap = {}; + try { + await yahooSleep(400); + aumMap = await fetchEtfAum(SECTOR_ETFS.map(s => s.etf)); + } catch { /* AUM 可缺 */ } + + const sectors = SECTOR_ETFS.map(meta => { + const points = pts[meta.etf]; + if (!points?.length) return { ...meta, error: 'no_data' }; + return buildSectorRow(meta, points, spyPoints, aumMap[meta.etf] ?? null); + }).filter(Boolean); + + const rotation = summarizeRotation(sectors.filter(s => !s.error)); + const okRows = sectors.filter(s => !s.error); + const institutional = institutionalView(okRows); + if (stockExposure && rotation?.leader) { + stockExposure = buildStockExposure( + Object.fromEntries((stockExposure.packs || []).map(p => [p.etf, p.holdings])), + rotation, + okRows, + ); + } + + return { + updatedAt: new Date().toISOString(), + benchmark: BENCHMARK, + source: 'Yahoo Finance · SPDR 行業 ETF', + sectors, + rotation, + institutional, + stockExposure, + heatmapWindow: { d1: '1日', d5: '5日', d20: '20日', d60: '約60日' }, + }; +} \ No newline at end of file diff --git a/lib/watchlist.js b/lib/watchlist.js new file mode 100644 index 0000000..8469df8 --- /dev/null +++ b/lib/watchlist.js @@ -0,0 +1,80 @@ +// 追蹤個股:分群清單(持久化於 SQLite KV,與財報日曆 watchlist 分開) +import { getCachedEntry, putCachedJSON } from './db.js'; + +const STORE_KEY = 'stock:watchlist:v1'; +const MAX_GROUPS = 24; +const MAX_SYMBOLS_PER_GROUP = 48; +const MAX_SYMBOLS_TOTAL = 200; + +export const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/; + +function newGroupId() { + return `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function defaultWatchlist() { + return { + groups: [ + { id: 'default', name: '我的追蹤', symbols: [], order: 0 }, + ], + updatedAt: new Date().toISOString(), + }; +} + +function cleanSymbol(s) { + const sym = String(s || '').trim().toUpperCase(); + return SYMBOL_RE.test(sym) ? sym : null; +} + +function normalizeGroup(g, idx) { + const id = String(g?.id || '').trim() || newGroupId(); + const name = String(g?.name || '').trim().slice(0, 40) || '未命名分群'; + const symbols = [...new Set((g?.symbols || []).map(cleanSymbol).filter(Boolean))].slice(0, MAX_SYMBOLS_PER_GROUP); + return { id, name, symbols, order: Number.isFinite(g?.order) ? g.order : idx }; +} + +/** 驗證並正規化前端/API 送來的完整結構 */ +export function normalizeWatchlistPayload(raw) { + const base = defaultWatchlist(); + if (!raw || typeof raw !== 'object') return base; + let groups = Array.isArray(raw.groups) ? raw.groups.map(normalizeGroup) : base.groups; + if (!groups.length) groups = base.groups; + groups = groups.slice(0, MAX_GROUPS).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, 'zh-Hant')); + const seenSym = new Set(); + for (const g of groups) { + g.symbols = g.symbols.filter(sym => { + if (seenSym.has(sym) || seenSym.size >= MAX_SYMBOLS_TOTAL) return false; + seenSym.add(sym); + return true; + }); + } + if (!groups.some(g => g.id === 'default')) { + groups.unshift({ id: 'default', name: '我的追蹤', symbols: [], order: -1 }); + } + groups.forEach((g, i) => { g.order = i; }); + return { groups, updatedAt: new Date().toISOString() }; +} + +export function getStockWatchlist() { + const row = getCachedEntry(STORE_KEY); + const val = row?.value; + if (!val?.groups) return defaultWatchlist(); + return normalizeWatchlistPayload(val); +} + +export function saveStockWatchlist(payload) { + const normalized = normalizeWatchlistPayload(payload); + putCachedJSON(STORE_KEY, normalized); + return normalized; +} + +export function allWatchlistSymbols(data) { + const out = []; + const seen = new Set(); + for (const g of data?.groups || []) { + for (const sym of g.symbols || []) { + if (!seen.has(sym)) { seen.add(sym); out.push(sym); } + } + } + return out; +} \ No newline at end of file diff --git a/lib/yahoo-session.js b/lib/yahoo-session.js new file mode 100644 index 0000000..8937def --- /dev/null +++ b/lib/yahoo-session.js @@ -0,0 +1,72 @@ +// 共用 Yahoo Finance cookie/crumb(避免多模組並行請求互相打掛) +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36'; + +let _auth = { cookie: null, crumb: null, at: 0 }; +let _inflight = null; +let _queue = Promise.resolve(); + +export function resetYahooAuth() { + _auth = { cookie: null, crumb: null, at: 0 }; +} + +export async function yahooAuth(force = false) { + if (!force && _auth.crumb && Date.now() - _auth.at < 3600e3) return _auth; + if (_inflight) return _inflight; + _inflight = (async () => { + const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null); + const cookie = (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; + })().finally(() => { _inflight = null; }); + return _inflight; +} + +async function yahooJson(url, retry = true) { + const { cookie, crumb } = await yahooAuth(); + const sep = url.includes('?') ? '&' : '?'; + const full = `${url}${sep}crumb=${encodeURIComponent(crumb)}`; + const res = await fetch(full, { headers: { 'User-Agent': UA, Cookie: cookie } }); + if ((res.status === 401 || res.status === 429) && retry) { + resetYahooAuth(); + await sleep(500); + await yahooAuth(true); + return yahooJson(url, false); + } + if (!res.ok) throw new Error(`Yahoo HTTP ${res.status}`); + return res.json(); +} + +function yahooQueued(fn) { + const run = _queue.then(() => fn()); + _queue = run.catch(() => {}); + return run; +} + +/** quoteSummary 模組(assetProfile、topHoldings 等)— 序列化避免並行打掛 crumb */ +export async function yahooQuoteSummary(symbol, modules) { + return yahooQueued(async () => { + const mod = Array.isArray(modules) ? modules.join(',') : modules; + const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${encodeURIComponent(mod)}`; + const j = await yahooJson(url); + await sleep(120); + return j?.quoteSummary?.result?.[0] || null; + }); +} + +export async function yahooFinanceSearchNews(symbol, count = 12) { + return yahooQueued(async () => { + const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}&newsCount=${count}"esCount=0`; + const j = await yahooJson(url); + await sleep(120); + return j?.news || []; + }); +} + +export function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} \ No newline at end of file diff --git a/server.js b/server.js index 81183fc..bc9bcb5 100644 --- a/server.js +++ b/server.js @@ -22,18 +22,26 @@ import { saveScoreSnapshot, getScoreHistory, listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats, getCachedJSON, putCachedJSON, getCachedEntry, + getCompanyIntelCustom, saveCompanyIntelCustom, } from './lib/db.js'; +import { mergeCustomIntel, localizeIntel } from './lib/companyintel-i18n.js'; +import { ensurePriceHistory, buildVolumeSeries } from './lib/price-store.js'; import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js'; import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js'; import { buildReport } from './lib/fincheck.js'; -import { getHistory, getHistorySince, RANGES, INTERVALS } from './lib/marketdata.js'; +import { RANGES, INTERVALS } from './lib/marketdata.js'; import { runBacktest, STRATEGIES } from './lib/backtest.js'; import { getInvestMap } from './lib/investmap.js'; import { buildGraph } from './lib/graph.js'; import { getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache, } from './lib/calendar-cache.js'; -import { getCompanyIntel } from './lib/companyintel.js'; +import { getCompanyIntel, runCompanyIntelSync } from './lib/companyintel.js'; +import { syncSecArchive, getSecArchivePayload, resolveArchiveFile } from './lib/sec-archive.js'; +import { buildSectorFlowPayload } from './lib/sector-flow.js'; +import { + getStockWatchlist, saveStockWatchlist, normalizeWatchlistPayload, allWatchlistSymbols, +} from './lib/watchlist.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ENV_PATH = path.join(__dirname, '.env'); @@ -52,6 +60,7 @@ 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 SECTOR_TTL_MS = (Number(process.env.SECTOR_TTL_HOURS) || 6) * 3600 * 1000; 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'; @@ -167,6 +176,26 @@ app.get('/api/macro', async (req, res) => { // 歷史事件標記 & 危機案例(靜態設定,給走勢標註與「歷史殷鑑」頁用) app.get('/api/events', (req, res) => res.json({ events: EVENTS, episodes: EPISODES })); +app.get('/api/sectors', async (req, res) => { + const key = 'sectors:flow:v1'; + const entry = getCachedEntry(key); + const fresh = req.query.fresh === '1'; + try { + if (!fresh && entry && Date.now() - entry.updatedAt < SECTOR_TTL_MS) { + return res.json({ ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() }); + } + const payload = await buildSectorFlowPayload(); + putCachedJSON(key, payload); + res.json({ ...payload, cached: false }); + } catch (err) { + console.error('[api/sectors]', err?.message || err); + if (entry?.value) { + return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) }); + } + res.status(502).json({ error: 'sectors_failed', message: String(err?.message || err) }); + } +}); + // 單一指標歷史序列(給走勢大圖) const RANGE_DAYS = { '1m': 30, '6m': 182, '1y': 365, '5y': 1825, '10y': 3650, max: null }; app.get('/api/series/:key', (req, res) => { @@ -259,35 +288,82 @@ function trimHistoryRange(payload, range) { 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); +async function enrichTodayVolume(payload, symbol, refreshQuote) { + if (payload.interval !== '1d') return payload; + let quote = getCachedEntry(`quote:${symbol}`)?.value || {}; + const needQuote = refreshQuote || quote.volume == null; + if (needQuote) { + try { + const q = await getQuote(symbol); + quote = { symbol, ...q }; + putCachedJSON(`quote:${symbol}`, { ...quote, _fetchedAt: Date.now() }); + } catch (_) { /* 沿用快取 */ } + } + const volumePoints = buildVolumeSeries( + payload.points, + payload.allBarsPoints || payload.points, + quote, + '1d', + ); + const today = new Date().toISOString().slice(0, 10); + const todayBar = volumePoints.find(p => p.date === today); + const todayVolume = todayBar?.volume ?? quote.volume ?? null; + const avgVolume = quote.avgVolume ?? null; return { - ...oldPayload, - ...patchPayload, - points, - _lastIncrementalAt: Date.now(), - _incremental: true, + ...payload, + volumePoints, + todayVolume, + avgVolume, + volumeRatio: todayVolume != null && avgVolume ? todayVolume / avgVolume : null, + volumeNote: todayBar?.partialSession + ? '當日成交量來自即時報價(收盤 K 仍截至昨日完整棒)' + : (todayVolume != null ? '含當日成交量' : null), }; } + async function getHistoryCached(symbol, range, interval, fresh) { - const key = `hist:${symbol}:${range}:${interval}`; - const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000; - const entry = getCachedEntry(key); - if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...trimHistoryRange(entry.value, range), cached: true }; + const ttl = interval === '1mo' ? 7 * 24 * 3600 * 1000 + : interval === '1wk' ? 24 * 3600 * 1000 + : HIST_TTL_MS; try { - 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() }; - putCachedJSON(key, payload); - return { ...trimHistoryRange(payload, range), cached: false }; + const { payload, cached, fetchMode } = await ensurePriceHistory(symbol, interval, { + fresh: fresh === true, + ttlMs: ttl, + }); + let enriched = await enrichTodayVolume(payload, symbol, fresh === true); + const trimmed = trimHistoryRange({ ...enriched, range }, range); + if (trimmed.volumePoints) { + const since = PRICE_RANGE_DAYS[range] + ? new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10) + : null; + trimmed.volumePoints = since + ? trimmed.volumePoints.filter(p => p.date >= since) + : trimmed.volumePoints; + } + return { + ...trimmed, + cached, + stale: !!payload.fetchError, + fetchError: payload.fetchError || null, + fetchMode, + dbBars: payload.dbBars, + researchBars: payload.researchBars, + researchThrough: payload.researchThrough, + researchNote: payload.researchNote, + firstDate: payload.firstDate, + lastDate: payload.lastDate, + }; } catch (err) { - if (entry) return { ...trimHistoryRange(entry.value, range), cached: true, stale: true, fetchError: String(err?.message || err) }; + const legacyKey = `hist:${symbol}:max:${interval}`; + const entry = getCachedEntry(legacyKey); + if (entry) { + return { + ...trimHistoryRange({ ...entry.value, range }, range), + cached: true, + stale: true, + fetchError: String(err?.message || err), + }; + } throw err; } } @@ -346,6 +422,21 @@ app.get('/api/profile/:symbol', async (req, res) => { } }); +function intelPayloadStale(payload, symbol) { + if (!payload) return true; + const goog = (payload.management?.searches || []).some(s => /google\.com\/search/i.test(s.url || '')) + || (payload.industryChain?.searches || []).some(s => /google\.com\/search/i.test(s.url || '')); + if (goog) return true; + const us = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.'); + if (us && !(payload.resources || []).length) return true; + const groups = [...(payload.industryChain?.upstreamDetail || []), ...(payload.industryChain?.downstreamDetail || [])]; + const hasObjEntities = groups.some(g => (g.entities || []).some(e => e && typeof e === 'object' && e.symbol)); + if (groups.length && !hasObjEntities) return true; + if ((payload.industryChain?.peers || []).length > 0) return true; + if (payload.chainLayout !== 'upstream_downstream_v2') return true; + return false; +} + 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: '代號格式不正確。' }); @@ -353,11 +444,27 @@ app.get('/api/company-intel/:symbol', async (req, res) => { 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 cacheOk = !fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS && !intelPayloadStale(entry.value, symbol); + if (cacheOk) { + const custom = getCompanyIntelCustom(symbol); + const { sanitizeIntelNewsPayload } = await import('./lib/companyintel.js'); + let payload = custom?.data + ? mergeCustomIntel(localizeIntel(entry.value), custom.data) + : entry.value; + payload = sanitizeIntelNewsPayload(payload); + const { attachIntelSyncStatus } = await import('./lib/companyintel-ai.js'); + payload = attachIntelSyncStatus(payload, symbol); + return res.json({ ...payload, cached: true }); + } const profile = getCachedEntry(`profile:${symbol}`)?.value || {}; - const payload = await getCompanyIntel(symbol, profile); + const doSync = req.query.sync === '1'; + const payload = await getCompanyIntel(symbol, profile, { + sync: doSync, + force: fresh, + useAI: req.query.ai !== '0', + }); putCachedJSON(key, payload); - res.json({ ...payload, cached: false }); + res.json({ ...payload, cached: false, synced: doSync }); } 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) }); @@ -365,6 +472,95 @@ app.get('/api/company-intel/:symbol', async (req, res) => { } }); +app.put('/api/company-intel/:symbol/custom', (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 body = req.body; + if (!body || typeof body !== 'object') { + return res.status(400).json({ error: 'bad_body', message: '請提供 JSON 物件。' }); + } + try { + const saved = saveCompanyIntelCustom(symbol, body); + const intelKey = `intel:${symbol}`; + const entry = getCachedEntry(intelKey); + if (entry?.value) { + putCachedJSON(intelKey, mergeCustomIntel(localizeIntel(entry.value), body)); + } + res.json({ ok: true, symbol: saved.symbol, updatedAt: saved.updatedAt }); + } catch (err) { + res.status(400).json({ error: 'save_failed', message: String(err?.message || err) }); + } +}); + +app.get('/api/company-intel/:symbol/custom', (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 row = getCompanyIntelCustom(symbol); + res.json(row ? { symbol, data: row.data, updatedAt: row.updatedAt } : { symbol, data: null }); +}); + +app.post('/api/company-intel/:symbol/sync', 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 force = req.query.fresh === '1' || req.body?.force === true; + const useAI = req.body?.useAI !== false && req.query.ai !== '0'; + try { + const profile = getCachedEntry(`profile:${symbol}`)?.value || {}; + const result = await runCompanyIntelSync(symbol, profile, { force, useAI }); + const intelKey = `intel:${symbol}`; + const payload = result.skipped + ? await getCompanyIntel(symbol, profile, { sync: false }) + : await getCompanyIntel(symbol, profile, { sync: false, force: true }); + putCachedJSON(intelKey, payload); + res.json({ + ok: true, + symbol, + skipped: result.skipped, + skipReason: result.skipReason || null, + nextRefreshAfter: result.nextRefreshAfter || payload.nextRefreshAfter, + nextPublicLabel: result.nextPublicLabel || payload.nextPublicLabel, + aiError: result.aiError || null, + sources: result.sources, + enrichedAt: payload.enrichedAt, + intel: payload, + }); + } catch (err) { + console.error('[api/company-intel/sync]', symbol, err?.message || err); + res.status(502).json({ error: 'intel_sync_failed', message: String(err?.message || err) }); + } +}); + +app.get('/api/sec-archive/:symbol', (req, res) => { + const symbol = String(req.params.symbol || '').trim().toUpperCase(); + if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' }); + res.json(getSecArchivePayload(symbol)); +}); + +app.post('/api/sec-archive/:symbol/sync', 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 force = req.query.fresh === '1' || req.body?.force === true; + try { + const payload = await syncSecArchive(symbol, { force }); + res.json(payload); + } catch (err) { + console.error('[api/sec-archive/sync]', symbol, err?.message || err); + res.status(502).json({ error: 'sec_archive_failed', message: String(err?.message || err) }); + } +}); + +app.get('/api/sec-archive/:symbol/file', (req, res) => { + const symbol = String(req.params.symbol || '').trim().toUpperCase(); + const accession = String(req.query.accession || '').trim(); + const file = String(req.query.file || '').trim(); + if (!SYMBOL_RE.test(symbol) || !accession) { + return res.status(400).json({ error: 'bad_request', message: '需要 accession。' }); + } + const full = resolveArchiveFile(symbol, accession, file || undefined); + if (!full) return res.status(404).json({ error: 'not_found', message: '本機尚無此檔案,請先同步封存。' }); + res.sendFile(full); +}); + function addDaysISO(base, days) { const d = new Date(base + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + days); @@ -382,6 +578,51 @@ app.put('/api/calendar/watchlist', (req, res) => { const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase()))); res.json({ ok: true, symbols }); }); + +app.get('/api/watchlist', (req, res) => { + const data = getStockWatchlist(); + res.json({ ...data, symbolCount: allWatchlistSymbols(data).length }); +}); +app.put('/api/watchlist', (req, res) => { + const data = saveStockWatchlist(req.body); + res.json({ ok: true, ...data, symbolCount: allWatchlistSymbols(data).length }); +}); +app.get('/api/watchlist/quotes', async (req, res) => { + const symbols = [...new Set(String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(s => SYMBOL_RE.test(s)))].slice(0, 48); + if (!symbols.length) return res.json({ quotes: [] }); + const quotes = await Promise.all(symbols.map(async (symbol) => { + try { + const key = `quote:${symbol}`; + let q = getCachedEntry(key)?.value; + if (q?.price == null) { + q = await getQuote(symbol); + putCachedJSON(key, { symbol, ...q, _fetchedAt: Date.now() }); + } + let chg = q?.changePercent; + if (chg == null) { + try { + const h = await getHistoryCached(symbol, '3mo', '1d', false); + const p = h?.points || []; + if (p.length >= 2 && p[0].close > 0) { + chg = ((p[p.length - 1].close / p[0].close) - 1) * 100; + } + } catch { /* skip */ } + } + return { + symbol, + name: q?.name || q?.shortName || symbol, + price: q?.price ?? null, + change: q?.change ?? null, + changePercent: chg ?? null, + currency: q?.currency || 'USD', + }; + } catch (e) { + return { symbol, error: String(e?.message || e) }; + } + })); + res.json({ quotes }); +}); + 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; @@ -492,6 +733,26 @@ function compactForPrompt(v, max = 16000) { const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2); return s.length > max ? s.slice(0, max) + '\n...(上下文已截斷)' : s; } +/** 依實際附帶的資料決定 page / chat,避免「在資料頁但沒資料」仍強制套用分析格式 */ +function finalizeAIContext(ctx = {}) { + const view = String(ctx.view || '').trim(); + let hasPageData = false; + if (view === 'macro') { + const m = ctx.macro; + hasPageData = !!(m && (m.score != null || m.focusedCard || (m.signals && m.signals.length))); + } else if (view === 'stock') { + const s = ctx.stock; + hasPageData = !!(s && !s.error && (s.fundamentals || s.quote || (s.history?.points?.length > 0) || s.technical?.close != null)); + } else if (view === 'calendar') { + hasPageData = !!(ctx.calendar?.events?.length); + } else if (view === 'journal') { + hasPageData = !!(ctx.journal?.trades?.length || ctx.journal?.stats); + } else if (view === 'learn') { + const n = ctx.learning?.focusedNote; + hasPageData = !!(n?.body || n?.title || (ctx.learning?.visibleText || '').trim().length > 80); + } + return { ...ctx, view, hasPageData, mode: hasPageData ? 'page' : 'chat' }; +} function cachedValue(entry) { if (!entry) return null; return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() }; @@ -551,15 +812,21 @@ async function stockAIContext(symbol, focus, allowFetch) { quoteEntry = getCachedEntry(`quote:${symbol}`); out.sources.push('quote:fetched'); } - let histEntry = getCachedEntry(`hist:${symbol}:max:1d`); - if (!histEntry && allowFetch) { - await getHistoryCached(symbol, 'max', '1d', false).catch(() => null); - histEntry = getCachedEntry(`hist:${symbol}:max:1d`); - if (histEntry) out.sources.push('history:fetched'); + let histPayload = null; + if (allowFetch) { + try { + const h = await ensurePriceHistory(symbol, '1d', { fresh: false, ttlMs: HIST_TTL_MS }); + histPayload = h.payload; + out.sources.push(h.fetchMode ? `history:${h.fetchMode}` : 'history:db'); + } catch (_) { /* 允許缺歷史 */ } } const fundamentals = cachedValue(fundEntry); const quote = cachedValue(quoteEntry); - const history = cachedValue(histEntry); + const history = histPayload ? { + ...histPayload, + cached: true, + cachedAt: histPayload._fetchedAt ? new Date(histPayload._fetchedAt).toISOString() : null, + } : null; out.fundamentals = fundamentals ? { symbol: fundamentals.symbol, name: fundamentals.name, @@ -579,14 +846,12 @@ async function stockAIContext(symbol, focus, allowFetch) { out.cacheStatus = { fundamentals: !!fundEntry, quote: !!quoteEntry, - history: !!histEntry, + history: !!(histPayload?.points?.length), }; return out; } async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) { const base = { - mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat', - hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view), view, focus, client, @@ -602,7 +867,10 @@ async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = base.macro = summarizeMacro(saved?.payload || cache.payload, focus); } else if (view === 'stock') { const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase(); - if (symbol) base.stock = await stockAIContext(symbol, focus, allowFetch); + if (symbol) { + base.stock = await stockAIContext(symbol, focus, allowFetch); + if (client.technical) base.stock.technical = client.technical; + } } else if (view === 'calendar') { const symbols = getCalendarWatchlist(); const today = new Date().toISOString().slice(0, 10); @@ -649,7 +917,7 @@ async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = personalNotes: client.personalNotes || [], }; } - return base; + return finalizeAIContext(base); } function normalizeModelList(data) { const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : []; @@ -709,7 +977,7 @@ app.post('/api/ai/chat', async (req, res) => { const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim(); let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim(); const question = String(req.body?.question || '').trim(); - const context = req.body?.context || {}; + const context = finalizeAIContext(req.body?.context || {}); if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' }); if (!model) { const models = await listProviderModels(provider, apiKey).catch(() => []); @@ -717,25 +985,24 @@ app.post('/api/ai/chat', async (req, res) => { } if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' }); if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' }); - const hasPageData = context?.mode === 'page' || context?.hasPageData === true; + const hasPageData = context.hasPageData === true; const system = hasPageData ? [ '你是 MacroScope 的投資學習助理。', - '使用者正在帶著頁面上下文提問,請根據提供的財報、總經、學習資料或交易復盤做分析與對照。', - '請用繁體中文回答,先給結論,再列出依據、矛盾點、下一步可以在頁面上檢查什麼。', - '不要聲稱已即時查網路;若資料不足,要明確說資料不足。', + '使用者正在 App 某個頁面提問,並附上該頁可取得的結構化資料(可能不完整)。', + '請優先根據附帶資料回答;資料未提及的不要捏造。語氣自然,像教學對話即可,不必強制固定章節格式,除非使用者要求條列或摘要。', + '若資料不足以回答,請直接說明缺什麼、建議在畫面上查看哪裡。', + '不要聲稱已即時查網路。', '內容僅供學習,不構成投資建議。', ].join('\n') : [ '你是 MacroScope 的 AI 助手。', - '目前沒有可用頁面資料,請把這次對話當一般聊天或一般投資學習問答處理。', - '請用繁體中文自然回答;如果使用者問投資或財務判斷,要提醒內容僅供學習,不構成投資建議。', - '不要聲稱已即時查網路;需要即時資料時,請說明你需要使用者提供資料或切到相關頁面。', - ].join('\n'); - const user = [ - `使用者問題:${question}`, - '', - hasPageData ? '目前頁面上下文:' : '對話狀態:', - hasPageData ? compactForPrompt(context) : compactForPrompt({ mode: 'chat', view: context?.view || '', collectedAt: context?.collectedAt || '' }), + '這是一般對話,沒有附帶頁面結構化資料。請用繁體中文自然回答,像一般聊天即可。', + '若使用者問投資或財務判斷,可給教學性說明,並提醒僅供學習、不構成投資建議。', + '需要本 App 內的總經、個股、日曆或筆記資料時,請建議使用者切到對應頁面後再問。', + '不要聲稱已即時查網路。', ].join('\n'); + const user = hasPageData + ? [`使用者問題:${question}`, '', '目前頁面上下文(JSON):', compactForPrompt(context)].join('\n') + : [`使用者問題:${question}`, '', `目前所在視圖:${context.view || '(未知)'}(無可用頁面資料,請當一般對話)`].join('\n'); try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 120000);