322 lines
15 KiB
HTML
322 lines
15 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8" />
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|||
|
|
<title>A股大事记录 · 大周期择时看板</title>
|
|||
|
|
<script src="./vendor/echarts.min.js"></script>
|
|||
|
|
<style>
|
|||
|
|
:root{
|
|||
|
|
--bg:#0f1420; --panel:#161d2e; --panel2:#1c2740; --line:#2a3654;
|
|||
|
|
--txt:#e6ecf7; --muted:#8ea0c0; --up:#ef4444; --down:#22c55e;
|
|||
|
|
--accent:#4f8cff; --warn:#f59e0b;
|
|||
|
|
}
|
|||
|
|
*{box-sizing:border-box}
|
|||
|
|
body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",Segoe UI,sans-serif;
|
|||
|
|
background:var(--bg);color:var(--txt);font-size:14px}
|
|||
|
|
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
|
|||
|
|
padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line)}
|
|||
|
|
header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.5px}
|
|||
|
|
.idx-btns{display:flex;gap:8px}
|
|||
|
|
.idx-btns button{background:var(--panel2);border:1px solid var(--line);color:var(--muted);
|
|||
|
|
padding:6px 12px;border-radius:6px;cursor:pointer;transition:.15s}
|
|||
|
|
.idx-btns button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|||
|
|
.idx-btns button:hover{color:var(--txt)}
|
|||
|
|
.ctrls{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
|
|||
|
|
input,select,textarea{background:var(--panel2);border:1px solid var(--line);color:var(--txt);
|
|||
|
|
padding:6px 8px;border-radius:6px;font-size:13px}
|
|||
|
|
button.act{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:6px 12px;
|
|||
|
|
border-radius:6px;cursor:pointer}
|
|||
|
|
button.act:hover{filter:brightness(1.1)}
|
|||
|
|
button.ghost{background:transparent;border:1px solid var(--line);color:var(--muted);padding:6px 12px;
|
|||
|
|
border-radius:6px;cursor:pointer}
|
|||
|
|
#status{font-size:12px;color:var(--muted);padding:4px 18px;background:var(--panel)}
|
|||
|
|
#chart{width:100%;height:640px}
|
|||
|
|
.bottom{display:grid;grid-template-columns:1.3fr 1fr;gap:14px;padding:14px 18px}
|
|||
|
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
|
|||
|
|
.card h3{margin:0 0 10px;font-size:14px;font-weight:600}
|
|||
|
|
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|||
|
|
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
|
|||
|
|
th{color:var(--muted);font-weight:500}
|
|||
|
|
.tag{padding:1px 7px;border-radius:10px;font-size:11px}
|
|||
|
|
.bull{background:rgba(239,68,68,.15);color:#ff8080}
|
|||
|
|
.bear{background:rgba(34,197,94,.15);color:#7ee6a0}
|
|||
|
|
.neu{background:rgba(142,160,192,.15);color:var(--muted)}
|
|||
|
|
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|||
|
|
.form-grid label{display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--muted)}
|
|||
|
|
.form-grid .full{grid-column:1/3}
|
|||
|
|
.del{color:#ff8080;cursor:pointer;background:none;border:none;font-size:12px}
|
|||
|
|
.hint{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:8px}
|
|||
|
|
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:middle}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<header>
|
|||
|
|
<h1>📈 A股大事记录 · 大周期择时看板</h1>
|
|||
|
|
<div class="idx-btns" id="idxBtns"></div>
|
|||
|
|
<div class="ctrls">
|
|||
|
|
<input type="date" id="start" />
|
|||
|
|
<span style="color:var(--muted)">→</span>
|
|||
|
|
<input type="date" id="end" />
|
|||
|
|
<button class="act" onclick="reload()">刷新</button>
|
|||
|
|
<button class="ghost" onclick="doSync()" title="从现有内网库同步指数/情绪(需配置 DSN)">同步数据</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
<div id="status">加载中…</div>
|
|||
|
|
<div id="chart"></div>
|
|||
|
|
|
|||
|
|
<div class="bottom">
|
|||
|
|
<div class="card">
|
|||
|
|
<h3>重大事件</h3>
|
|||
|
|
<table id="evtTable"><thead>
|
|||
|
|
<tr><th>日期</th><th>标题</th><th>类别</th><th>方向</th><th>级别</th><th>顶底</th><th></th></tr>
|
|||
|
|
</thead><tbody></tbody></table>
|
|||
|
|
<div class="hint">点击图上事件标记或此表可定位。事件可 CSV 批量导入:<code>POST /api/events/import</code>(模板见 ddl/event_import_template.csv)。</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<h3>新增事件(人工录入)</h3>
|
|||
|
|
<div class="form-grid">
|
|||
|
|
<label>日期<input type="date" id="f_date" /></label>
|
|||
|
|
<label>类别
|
|||
|
|
<select id="f_cat"><option>监管</option><option>IPO</option><option>政策</option>
|
|||
|
|
<option>资金</option><option>外部</option><option selected>其他</option></select></label>
|
|||
|
|
<label class="full">标题<input type="text" id="f_title" placeholder="如:证监会立案某券商 / 某巨无霸IPO上市" /></label>
|
|||
|
|
<label>方向
|
|||
|
|
<select id="f_dir"><option value="bullish">利多</option>
|
|||
|
|
<option value="bearish">利空</option><option value="neutral" selected>中性</option></select></label>
|
|||
|
|
<label>影响级别
|
|||
|
|
<select id="f_sev"><option>1</option><option>2</option><option selected>3</option>
|
|||
|
|
<option>4</option><option>5</option></select></label>
|
|||
|
|
<label>顶底标记
|
|||
|
|
<select id="f_cycle"><option value="none" selected>无</option>
|
|||
|
|
<option value="top">顶部信号</option><option value="bottom">底部信号</option></select></label>
|
|||
|
|
<label>关联指数(可空)
|
|||
|
|
<input type="text" id="f_rel" placeholder="000001.SH,399006.SZ" /></label>
|
|||
|
|
<label class="full">说明<textarea id="f_desc" rows="2"></textarea></label>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top:10px"><button class="act" onclick="addEvent()">保存事件</button></div>
|
|||
|
|
<div class="hint">
|
|||
|
|
<span class="legend-dot" style="background:#ef4444"></span>利多
|
|||
|
|
<span class="legend-dot" style="background:#22c55e"></span>利空
|
|||
|
|
<span class="legend-dot" style="background:#8ea0c0"></span>中性 ·
|
|||
|
|
温度副图:<b style="color:#ef4444">红=过热(顶部风险)</b>,<b style="color:#3b82f6">蓝=冰点(底部机会)</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const API = "";
|
|||
|
|
const state = { code:null, list:[], k:null, events:[] };
|
|||
|
|
const CAT_COLOR = { bullish:"#ef4444", bearish:"#22c55e", neutral:"#8ea0c0" };
|
|||
|
|
const DIR_CN = { bullish:"利多", bearish:"利空", neutral:"中性" };
|
|||
|
|
let chart;
|
|||
|
|
|
|||
|
|
function fmt(d){ return d.toISOString().slice(0,10); }
|
|||
|
|
async function j(url, opts){
|
|||
|
|
const r = await fetch(API+url, opts);
|
|||
|
|
if(!r.ok){ throw new Error(await r.text()); }
|
|||
|
|
return r.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function init(){
|
|||
|
|
chart = echarts.init(document.getElementById("chart"), "dark");
|
|||
|
|
window.addEventListener("resize", ()=>chart.resize());
|
|||
|
|
const end = new Date(); const start = new Date(); start.setFullYear(end.getFullYear()-3);
|
|||
|
|
document.getElementById("start").value = fmt(start);
|
|||
|
|
document.getElementById("end").value = fmt(end);
|
|||
|
|
document.getElementById("f_date").value = fmt(end);
|
|||
|
|
try{
|
|||
|
|
state.list = await j("/api/index/list");
|
|||
|
|
}catch(e){ setStatus("加载指数列表失败:"+e.message); return; }
|
|||
|
|
renderIdxBtns();
|
|||
|
|
if(state.list.length){ state.code = state.list[0].code; await reload(); }
|
|||
|
|
else setStatus("自有库暂无指数数据,请先运行 ETL 同步(见 README)。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderIdxBtns(){
|
|||
|
|
const box = document.getElementById("idxBtns"); box.innerHTML="";
|
|||
|
|
state.list.forEach(it=>{
|
|||
|
|
const b=document.createElement("button");
|
|||
|
|
b.textContent = it.name + "("+it.code+")";
|
|||
|
|
b.className = it.code===state.code ? "active":"";
|
|||
|
|
b.onclick = ()=>{ state.code=it.code; renderIdxBtns(); reload(); };
|
|||
|
|
box.appendChild(b);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function setStatus(s){ document.getElementById("status").textContent = s; }
|
|||
|
|
|
|||
|
|
async function reload(){
|
|||
|
|
if(!state.code) return;
|
|||
|
|
const s = document.getElementById("start").value, e = document.getElementById("end").value;
|
|||
|
|
setStatus("加载中…");
|
|||
|
|
try{
|
|||
|
|
const [k, events] = await Promise.all([
|
|||
|
|
j(`/api/index/${state.code}/kline?start=${s}&end=${e}`),
|
|||
|
|
j(`/api/events?start=${s}&end=${e}`)
|
|||
|
|
]);
|
|||
|
|
state.k = k; state.events = events;
|
|||
|
|
renderChart(); renderEventTable();
|
|||
|
|
const srcTxt = k.ohlc_available ? "蜡烛图(已接入OHLC源)" : "收盘线(未接入OHLC源,仅收盘价)";
|
|||
|
|
setStatus(`${k.index_name} · ${k.dates.length} 交易日 · ${srcTxt} · 事件 ${events.length} 条`);
|
|||
|
|
}catch(err){ setStatus("加载失败:"+err.message); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nearestIdx(dates, target){
|
|||
|
|
// 最后一个 <= target 的下标
|
|||
|
|
let lo=0, hi=dates.length-1, ans=-1;
|
|||
|
|
while(lo<=hi){ const m=(lo+hi)>>1; if(dates[m]<=target){ans=m;lo=m+1;} else hi=m-1; }
|
|||
|
|
return ans;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderChart(){
|
|||
|
|
const k = state.k;
|
|||
|
|
const dates = k.dates;
|
|||
|
|
const priceLine = k.ohlc.map(x=>x[3]); // close
|
|||
|
|
const cs = k.ohlc.map(x=>[x[0],x[3],x[2],x[1]]); // [open,close,low,high]
|
|||
|
|
const volData = k.amount.map((a,i)=>({
|
|||
|
|
value: a==null? null : +(a/1e8).toFixed(2),
|
|||
|
|
itemStyle:{ color: (k.pct_chg[i]??0) >= 0 ? "#ef4444":"#22c55e" }
|
|||
|
|
}));
|
|||
|
|
const temp = k.temperature;
|
|||
|
|
|
|||
|
|
// 事件散点(吸附到最近交易日)
|
|||
|
|
const evPts = [];
|
|||
|
|
state.events.forEach(ev=>{
|
|||
|
|
const idx = nearestIdx(dates, ev.event_date);
|
|||
|
|
if(idx<0) return;
|
|||
|
|
const y = k.ohlc[idx][1] ?? k.ohlc[idx][3]; // high or close
|
|||
|
|
evPts.push({
|
|||
|
|
value:[dates[idx], y],
|
|||
|
|
symbol: ev.cycle_tag==="top"?"triangle": ev.cycle_tag==="bottom"?"triangle":"pin",
|
|||
|
|
symbolRotate: ev.cycle_tag==="bottom"?180:0,
|
|||
|
|
symbolSize: 8 + (ev.severity||3)*2,
|
|||
|
|
itemStyle:{ color: CAT_COLOR[ev.impact_direction]||"#8ea0c0" },
|
|||
|
|
_ev: ev
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const priceSeries = k.ohlc_available ? {
|
|||
|
|
name:"K线", type:"candlestick", data:cs,
|
|||
|
|
itemStyle:{color:"#ef4444",color0:"#22c55e",borderColor:"#ef4444",borderColor0:"#22c55e"}
|
|||
|
|
} : {
|
|||
|
|
name:"收盘", type:"line", data:priceLine, showSymbol:false,
|
|||
|
|
lineStyle:{width:1.5,color:"#e6ecf7"}, areaStyle:{color:"rgba(79,140,255,.08)"}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const option = {
|
|||
|
|
backgroundColor:"transparent",
|
|||
|
|
animation:false,
|
|||
|
|
axisPointer:{link:[{xAxisIndex:"all"}]},
|
|||
|
|
tooltip:{ trigger:"axis", axisPointer:{type:"cross"},
|
|||
|
|
backgroundColor:"rgba(20,28,48,.95)", borderColor:"#2a3654", textStyle:{color:"#e6ecf7"} },
|
|||
|
|
grid:[
|
|||
|
|
{left:60,right:24,top:20,height:"52%"},
|
|||
|
|
{left:60,right:24,top:"60%",height:"14%"},
|
|||
|
|
{left:60,right:24,top:"78%",height:"16%"}
|
|||
|
|
],
|
|||
|
|
xAxis:[
|
|||
|
|
{type:"category",data:dates,scale:true,boundaryGap:false,axisLine:{lineStyle:{color:"#2a3654"}},
|
|||
|
|
axisLabel:{show:false},splitLine:{show:false}},
|
|||
|
|
{type:"category",gridIndex:1,data:dates,axisLabel:{show:false},axisLine:{lineStyle:{color:"#2a3654"}},
|
|||
|
|
splitLine:{show:false}},
|
|||
|
|
{type:"category",gridIndex:2,data:dates,axisLine:{lineStyle:{color:"#2a3654"}},
|
|||
|
|
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
|
|||
|
|
],
|
|||
|
|
yAxis:[
|
|||
|
|
{scale:true,axisLabel:{color:"#8ea0c0"},splitLine:{lineStyle:{color:"rgba(42,54,84,.5)"}}},
|
|||
|
|
{scale:true,gridIndex:1,name:"成交额(亿)",nameTextStyle:{color:"#8ea0c0",fontSize:10},
|
|||
|
|
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}},
|
|||
|
|
{scale:true,gridIndex:2,min:0,max:100,name:"温度",nameTextStyle:{color:"#8ea0c0",fontSize:10},
|
|||
|
|
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
|
|||
|
|
],
|
|||
|
|
dataZoom:[
|
|||
|
|
{type:"inside",xAxisIndex:[0,1,2],start:60,end:100},
|
|||
|
|
{type:"slider",xAxisIndex:[0,1,2],bottom:6,height:16,start:60,end:100,
|
|||
|
|
borderColor:"#2a3654",textStyle:{color:"#8ea0c0"}}
|
|||
|
|
],
|
|||
|
|
visualMap:{ show:false, seriesIndex:4, dimension:1, min:0, max:100,
|
|||
|
|
inRange:{color:["#3b82f6","#93c5fd","#f59e0b","#ef4444"]} },
|
|||
|
|
series:[
|
|||
|
|
Object.assign({xAxisIndex:0,yAxisIndex:0}, priceSeries),
|
|||
|
|
{ name:"事件", type:"scatter", data:evPts, xAxisIndex:0, yAxisIndex:0, z:10,
|
|||
|
|
tooltip:{trigger:"item", formatter:p=>{
|
|||
|
|
const ev=p.data._ev; if(!ev) return "";
|
|||
|
|
return `<b>${ev.event_date}</b> · ${ev.category}<br/><b>${ev.title}</b><br/>`+
|
|||
|
|
`方向:<span style="color:${CAT_COLOR[ev.impact_direction]}">${DIR_CN[ev.impact_direction]||""}</span>`+
|
|||
|
|
` · 级别 ${ev.severity} ${ev.cycle_tag!=="none"?("· "+(ev.cycle_tag==="top"?"顶部":"底部")):""}<br/>`+
|
|||
|
|
`${ev.description? "<span style='color:#8ea0c0'>"+ev.description+"</span>":""}`;
|
|||
|
|
}}
|
|||
|
|
},
|
|||
|
|
{ name:"标注", type:"scatter", data:[], xAxisIndex:0, yAxisIndex:0 },
|
|||
|
|
{ name:"成交额", type:"bar", data:volData, xAxisIndex:1, yAxisIndex:1 },
|
|||
|
|
{ name:"市场温度", type:"line", data:temp, xAxisIndex:2, yAxisIndex:2, showSymbol:false,
|
|||
|
|
connectNulls:false, lineStyle:{width:1.5},
|
|||
|
|
markLine:{ silent:true, symbol:"none", label:{color:"#8ea0c0",fontSize:10},
|
|||
|
|
data:[
|
|||
|
|
{yAxis:k.thresholds?.hot??80, lineStyle:{color:"#ef4444",type:"dashed"}, label:{formatter:"过热"}},
|
|||
|
|
{yAxis:k.thresholds?.cold??20, lineStyle:{color:"#3b82f6",type:"dashed"}, label:{formatter:"冰点"}}
|
|||
|
|
]}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
chart.setOption(option, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEventTable(){
|
|||
|
|
const tb = document.querySelector("#evtTable tbody"); tb.innerHTML="";
|
|||
|
|
state.events.slice().reverse().forEach(ev=>{
|
|||
|
|
const cls = ev.impact_direction==="bullish"?"bull":ev.impact_direction==="bearish"?"bear":"neu";
|
|||
|
|
const tr=document.createElement("tr");
|
|||
|
|
tr.innerHTML =
|
|||
|
|
`<td>${ev.event_date}</td><td>${ev.title}</td><td>${ev.category}</td>`+
|
|||
|
|
`<td><span class="tag ${cls}">${DIR_CN[ev.impact_direction]||""}</span></td>`+
|
|||
|
|
`<td>${ev.severity}</td>`+
|
|||
|
|
`<td>${ev.cycle_tag==="top"?"顶":ev.cycle_tag==="bottom"?"底":"-"}</td>`+
|
|||
|
|
`<td><button class="del" onclick="delEvent(${ev.id})">删除</button></td>`;
|
|||
|
|
tb.appendChild(tr);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addEvent(){
|
|||
|
|
const payload = {
|
|||
|
|
event_date: document.getElementById("f_date").value,
|
|||
|
|
title: document.getElementById("f_title").value.trim(),
|
|||
|
|
category: document.getElementById("f_cat").value,
|
|||
|
|
impact_direction: document.getElementById("f_dir").value,
|
|||
|
|
severity: parseInt(document.getElementById("f_sev").value),
|
|||
|
|
cycle_tag: document.getElementById("f_cycle").value,
|
|||
|
|
related_indices: document.getElementById("f_rel").value.trim() || null,
|
|||
|
|
description: document.getElementById("f_desc").value.trim() || null
|
|||
|
|
};
|
|||
|
|
if(!payload.title){ alert("请填写标题"); return; }
|
|||
|
|
try{
|
|||
|
|
await j("/api/events", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
|
|||
|
|
document.getElementById("f_title").value=""; document.getElementById("f_desc").value="";
|
|||
|
|
await reload();
|
|||
|
|
}catch(e){ alert("保存失败:"+e.message); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function delEvent(id){
|
|||
|
|
if(!confirm("确认删除该事件?")) return;
|
|||
|
|
try{ await j("/api/events/"+id, {method:"DELETE"}); await reload(); }
|
|||
|
|
catch(e){ alert("删除失败:"+e.message); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function doSync(){
|
|||
|
|
const s = document.getElementById("start").value;
|
|||
|
|
if(!confirm("从现有内网库同步 "+s+" 至今 的指数与情绪数据?(需已配置 LEGACY_*_DSN)")) return;
|
|||
|
|
setStatus("同步中…可能耗时,请稍候");
|
|||
|
|
try{
|
|||
|
|
const r1 = await j("/api/sync/index",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
|
|||
|
|
const r2 = await j("/api/sync/sentiment",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
|
|||
|
|
setStatus("同步完成:"+JSON.stringify(r1.synced)+" / 情绪 "+r2.synced+" 行。刷新中…");
|
|||
|
|
state.list = await j("/api/index/list"); renderIdxBtns(); await reload();
|
|||
|
|
}catch(e){ setStatus("同步失败:"+e.message+"(检查 LEGACY_*_DSN 是否配置且内网可达)"); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|