Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List, Optional, Tuple
|
| 6 |
+
import re
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from fastapi import FastAPI, Query
|
| 9 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from pyecharts import options as opts
|
| 12 |
+
from pyecharts.charts import Line
|
| 13 |
+
from pyecharts.globals import CurrentConfig
|
| 14 |
+
from pyecharts.commons.utils import JsCode # <-- 关键:用于包装 JS 函数
|
| 15 |
+
|
| 16 |
+
# ---------------- 0) 路径 ----------------
|
| 17 |
+
DATA_DIR = Path(".")
|
| 18 |
+
WIDE_PATH = DATA_DIR / "bands_df.parquet" # 你的“宽表+追加bands”文件
|
| 19 |
+
DATE_COL = "trade_date"
|
| 20 |
+
INDEX_COL = "index_code"
|
| 21 |
+
|
| 22 |
+
# ---------------- 1) 读数 ----------------
|
| 23 |
+
if not WIDE_PATH.exists():
|
| 24 |
+
raise FileNotFoundError(
|
| 25 |
+
f"未找到 {WIDE_PATH}。请先保存:bands_appended.to_parquet('bands_appended.parquet')"
|
| 26 |
+
)
|
| 27 |
+
df = pd.read_parquet(WIDE_PATH).copy()
|
| 28 |
+
if DATE_COL not in df.columns or INDEX_COL not in df.columns:
|
| 29 |
+
raise ValueError(f"数据必须包含 {DATE_COL} 与 {INDEX_COL} 列。")
|
| 30 |
+
|
| 31 |
+
df[DATE_COL] = pd.to_datetime(df[DATE_COL])
|
| 32 |
+
df = df.sort_values([INDEX_COL, DATE_COL]).reset_index(drop=True)
|
| 33 |
+
df["date_str"] = df[DATE_COL].dt.strftime("%Y-%m-%d")
|
| 34 |
+
|
| 35 |
+
# ---------------- 2) 识别基础列 ----------------
|
| 36 |
+
def is_base_col(c: str) -> bool:
|
| 37 |
+
if c in (DATE_COL, INDEX_COL): return False
|
| 38 |
+
if not pd.api.types.is_numeric_dtype(df[c]): return False
|
| 39 |
+
return not any(tok in c for tok in ("_mu_", "_sigma_", "_band_", "_pct_"))
|
| 40 |
+
|
| 41 |
+
BASE_PATTERN = re.compile(r"^(pe_ttm|pb_lf|ps_ttm|pcf_ttm)_(weighted|median|total)$", re.I)
|
| 42 |
+
METRIC_MAP = {"pe_ttm": "PE-TTM", "pb_lf": "PB-LF", "ps_ttm": "PS-TTM", "pcf_ttm": "PCF-TTM"}
|
| 43 |
+
METHOD_MAP = {"weighted": "权重加权法", "median": "中值法", "total": "总量法"}
|
| 44 |
+
|
| 45 |
+
base_cols: List[str] = [c for c in df.columns if is_base_col(c)]
|
| 46 |
+
KEY_TO_BASE: Dict[Tuple[str, str], str] = {}
|
| 47 |
+
for c in base_cols:
|
| 48 |
+
m = BASE_PATTERN.match(c)
|
| 49 |
+
if not m: continue
|
| 50 |
+
metric_key, method_key = m.group(1).lower(), m.group(2).lower()
|
| 51 |
+
KEY_TO_BASE[(METRIC_MAP.get(metric_key, metric_key),
|
| 52 |
+
METHOD_MAP.get(method_key, method_key))] = c
|
| 53 |
+
|
| 54 |
+
INDEXES = sorted(df[INDEX_COL].unique().tolist())
|
| 55 |
+
METRICS = sorted({k[0] for k in KEY_TO_BASE})
|
| 56 |
+
METHODS = sorted({k[1] for k in KEY_TO_BASE})
|
| 57 |
+
WINDOWS = ["3Y", "10Y", "Expanding"]
|
| 58 |
+
|
| 59 |
+
# ---------------- 3) FastAPI ----------------
|
| 60 |
+
app = FastAPI(title="Valuation (pyecharts + FastAPI, offline, wide)")
|
| 61 |
+
|
| 62 |
+
static_dir = Path("./static")
|
| 63 |
+
static_dir.mkdir(exist_ok=True)
|
| 64 |
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
| 65 |
+
|
| 66 |
+
# ---------------- 4) 首页 ----------------
|
| 67 |
+
@app.get("/", response_class=HTMLResponse)
|
| 68 |
+
def index() -> str:
|
| 69 |
+
html = """
|
| 70 |
+
<!doctype html>
|
| 71 |
+
<html lang="zh-CN">
|
| 72 |
+
<head>
|
| 73 |
+
<meta charset="utf-8"/>
|
| 74 |
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 75 |
+
<title>指数估值分位报告(pyecharts + FastAPI,离线,宽表版)</title>
|
| 76 |
+
<style>
|
| 77 |
+
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,'Noto Sans',sans-serif;margin:16px;}
|
| 78 |
+
h1{margin:0 0 12px;font-size:20px;}
|
| 79 |
+
.controls{display:grid;gap:8px;grid-template-columns:repeat(4,minmax(160px,1fr));margin-bottom:12px;}
|
| 80 |
+
.control label{display:block;font-size:12px;color:#555;margin-bottom:4px;}
|
| 81 |
+
select{width:100%;padding:6px;border-radius:8px;border:1px solid #ddd;}
|
| 82 |
+
#chart{width:100%;height:560px;}
|
| 83 |
+
table{width:100%;border-collapse:collapse;margin-top:10px;}
|
| 84 |
+
th,td{border:1px solid #e6e6e6;padding:6px 8px;text-align:right;}
|
| 85 |
+
th:first-child,td:first-child{text-align:left;}
|
| 86 |
+
.muted{color:#777;font-size:12px;}
|
| 87 |
+
</style>
|
| 88 |
+
</head>
|
| 89 |
+
<body>
|
| 90 |
+
<h1>指数估值分位报告(pyecharts + FastAPI,离线,宽表版)</h1>
|
| 91 |
+
|
| 92 |
+
<div class="controls">
|
| 93 |
+
<div class="control"><label>指数 (Index)</label><select id="selIndex"></select></div>
|
| 94 |
+
<div class="control"><label>估值指标 (Metric)</label><select id="selMetric"></select></div>
|
| 95 |
+
<div class="control"><label>计算方法 (Method)</label><select id="selMethod"></select></div>
|
| 96 |
+
<div class="control"><label>历史窗口 (Window)</label>
|
| 97 |
+
<select id="selWindow"><option>3Y</option><option selected>10Y</option><option>Expanding</option></select>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div id="chart"></div>
|
| 102 |
+
|
| 103 |
+
<table id="summary">
|
| 104 |
+
<thead><tr>
|
| 105 |
+
<th>最新交易日</th><th>当前指标值</th>
|
| 106 |
+
<th>3Y分位(%)</th><th>10Y分位(%)</th><th>Expanding分位(%)</th>
|
| 107 |
+
<th>μ</th><th>μ-1σ</th><th>μ+1σ</th><th>μ-2σ</th><th>μ+2σ</th><th>μ-3σ</th><th>μ+3σ</th>
|
| 108 |
+
</tr></thead>
|
| 109 |
+
<tbody></tbody>
|
| 110 |
+
</table>
|
| 111 |
+
|
| 112 |
+
<p class="muted">完全离线:前端使用本地 /static/echarts.min.js;图表由后端 pyecharts 生成。</p>
|
| 113 |
+
|
| 114 |
+
<script src="/static/echarts.min.js"></script>
|
| 115 |
+
<script>
|
| 116 |
+
const selIndex = document.getElementById('selIndex');
|
| 117 |
+
const selMetric = document.getElementById('selMetric');
|
| 118 |
+
const selMethod = document.getElementById('selMethod');
|
| 119 |
+
const selWindow = document.getElementById('selWindow');
|
| 120 |
+
|
| 121 |
+
async function jget(url){ const r = await fetch(url); return await r.json(); }
|
| 122 |
+
async function getText(url){ const r = await fetch(url); return await r.text(); }
|
| 123 |
+
|
| 124 |
+
function injectHTMLAndRunScripts(container, html){
|
| 125 |
+
container.innerHTML = html;
|
| 126 |
+
|
| 127 |
+
// Collect scripts as array to avoid live NodeList issues
|
| 128 |
+
const scripts = Array.from(container.querySelectorAll('script'));
|
| 129 |
+
|
| 130 |
+
scripts.forEach(oldS => {
|
| 131 |
+
try {
|
| 132 |
+
const parent = oldS.parentNode;
|
| 133 |
+
const s = document.createElement('script');
|
| 134 |
+
|
| 135 |
+
// Copy attributes (type/src/async/etc.)
|
| 136 |
+
Array.from(oldS.attributes || []).forEach(attr => {
|
| 137 |
+
try {
|
| 138 |
+
s.setAttribute(attr.name, attr.value);
|
| 139 |
+
} catch(e) {
|
| 140 |
+
// 某些 attribute 可能不可复制,记录但不阻塞
|
| 141 |
+
console.warn('Failed to copy attribute', attr.name, attr.value, e);
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
if (oldS.src) {
|
| 146 |
+
// External script: append to head to ensure browser loads & executes it
|
| 147 |
+
// Use async=false to request execution order (best-effort)
|
| 148 |
+
s.src = oldS.src;
|
| 149 |
+
try { s.async = false; } catch(e){}
|
| 150 |
+
// Append and then remove the old node (if still attached)
|
| 151 |
+
document.head.appendChild(s);
|
| 152 |
+
if (parent && parent.contains(oldS)) {
|
| 153 |
+
try { parent.removeChild(oldS); } catch(e){ /* ignore */ }
|
| 154 |
+
}
|
| 155 |
+
} else {
|
| 156 |
+
// Inline script: copy text and replace if possible
|
| 157 |
+
const code = oldS.textContent || oldS.innerHTML || '';
|
| 158 |
+
s.text = code;
|
| 159 |
+
if (parent && parent.contains(oldS)) {
|
| 160 |
+
try {
|
| 161 |
+
parent.replaceChild(s, oldS);
|
| 162 |
+
} catch (e) {
|
| 163 |
+
// fallback: append to container
|
| 164 |
+
console.warn('replaceChild failed for inline script, appending instead', e);
|
| 165 |
+
container.appendChild(s);
|
| 166 |
+
try { parent.removeChild(oldS); } catch(e){}
|
| 167 |
+
}
|
| 168 |
+
} else {
|
| 169 |
+
// parent missing (script detached) -> just append
|
| 170 |
+
container.appendChild(s);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
} catch (err) {
|
| 174 |
+
// 打印出错信息并尽量继续处理其它脚本
|
| 175 |
+
console.error('injectHTMLAndRunScripts error while handling a script node:', err);
|
| 176 |
+
try {
|
| 177 |
+
console.log('Problematic script:', oldS && (oldS.src || oldS.textContent || oldS.innerHTML));
|
| 178 |
+
} catch(e){ console.warn('Could not read script content:', e); }
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
async function init(){
|
| 184 |
+
const meta = await jget('/meta');
|
| 185 |
+
selIndex.innerHTML = meta.indexes.map(v=>`<option value="${v}">${v}</option>`).join('');
|
| 186 |
+
selMetric.innerHTML = meta.metrics.map(v=>`<option value="${v}">${v}</option>`).join('');
|
| 187 |
+
selMethod.innerHTML = meta.methods.map(v=>`<option value="${v}">${v}</option>`).join('');
|
| 188 |
+
await render();
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
async function render(){
|
| 192 |
+
const params = new URLSearchParams({
|
| 193 |
+
index_code: selIndex.value, metric: selMetric.value, method: selMethod.value, window: selWindow.value
|
| 194 |
+
});
|
| 195 |
+
const chartHTML = await getText('/chart?' + params.toString());
|
| 196 |
+
injectHTMLAndRunScripts(document.getElementById('chart'), chartHTML);
|
| 197 |
+
|
| 198 |
+
const summary = await jget('/summary?' + params.toString());
|
| 199 |
+
const tbody = document.querySelector('#summary tbody');
|
| 200 |
+
const fmt = n => (n==null || isNaN(n)) ? '' : Number(n).toFixed(4);
|
| 201 |
+
tbody.innerHTML = `<tr>
|
| 202 |
+
<td style="text-align:left">${summary.date || ''}</td>
|
| 203 |
+
<td>${fmt(summary.value)}</td>
|
| 204 |
+
<td>${fmt(summary.pct_3Y)}</td>
|
| 205 |
+
<td>${fmt(summary.pct_10Y)}</td>
|
| 206 |
+
<td>${fmt(summary.pct_Expanding)}</td>
|
| 207 |
+
<td>${fmt(summary.mu)}</td>
|
| 208 |
+
<td>${fmt(summary.n1)}</td>
|
| 209 |
+
<td>${fmt(summary.p1)}</td>
|
| 210 |
+
<td>${fmt(summary.n2)}</td>
|
| 211 |
+
<td>${fmt(summary.p2)}</td>
|
| 212 |
+
<td>${fmt(summary.n3)}</td>
|
| 213 |
+
<td>${fmt(summary.p3)}</td>
|
| 214 |
+
</tr>`;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
selIndex.addEventListener('change', render);
|
| 218 |
+
selMetric.addEventListener('change', render);
|
| 219 |
+
selMethod.addEventListener('change', render);
|
| 220 |
+
selWindow.addEventListener('change', render);
|
| 221 |
+
|
| 222 |
+
init();
|
| 223 |
+
</script>
|
| 224 |
+
</body>
|
| 225 |
+
</html>
|
| 226 |
+
"""
|
| 227 |
+
return HTMLResponse(content=html)
|
| 228 |
+
|
| 229 |
+
# ---------------- 5) 元数据 ----------------
|
| 230 |
+
@app.get("/meta")
|
| 231 |
+
def meta() -> JSONResponse:
|
| 232 |
+
return JSONResponse({"indexes": INDEXES, "metrics": METRICS, "methods": METHODS, "windows": WINDOWS})
|
| 233 |
+
|
| 234 |
+
# ---------------- 6) 图表 ----------------
|
| 235 |
+
|
| 236 |
+
@app.get("/chart", response_class=HTMLResponse)
|
| 237 |
+
def chart_offline(
|
| 238 |
+
index_code: str = Query(...),
|
| 239 |
+
metric: str = Query(...),
|
| 240 |
+
method: str = Query(...),
|
| 241 |
+
window: str = Query(..., pattern="^(3Y|10Y|Expanding)$")
|
| 242 |
+
) -> str:
|
| 243 |
+
import json
|
| 244 |
+
|
| 245 |
+
base_col = KEY_TO_BASE.get((metric, method))
|
| 246 |
+
if base_col is None:
|
| 247 |
+
return HTMLResponse("<div style='padding:12px;color:#b00'>未找到对应列</div>")
|
| 248 |
+
|
| 249 |
+
g = df[df[INDEX_COL] == index_code].sort_values(DATE_COL)
|
| 250 |
+
if g.empty:
|
| 251 |
+
return HTMLResponse("<div style='padding:12px;color:#b00'>无该指数数据</div>")
|
| 252 |
+
|
| 253 |
+
# 根据窗口类型过滤数据,显示不同的起始点
|
| 254 |
+
mu_col = f"{base_col}_mu_{window}"
|
| 255 |
+
if mu_col not in g.columns:
|
| 256 |
+
return HTMLResponse("<div style='padding:12px;color:#b00'>无该窗口数据</div>")
|
| 257 |
+
|
| 258 |
+
# 找到第一个非NaN的数据点作为起始点
|
| 259 |
+
first_valid_idx = g[mu_col].first_valid_index()
|
| 260 |
+
if first_valid_idx is None:
|
| 261 |
+
return HTMLResponse("<div style='padding:12px;color:#b00'>该窗口无有效数据</div>")
|
| 262 |
+
|
| 263 |
+
# 从第一个有效数据点开始
|
| 264 |
+
g_filtered = g.loc[first_valid_idx:].copy()
|
| 265 |
+
|
| 266 |
+
dates = g_filtered["date_str"].tolist()
|
| 267 |
+
y_value = g_filtered[base_col].tolist()
|
| 268 |
+
|
| 269 |
+
def col_or_nan(c):
|
| 270 |
+
if c in g_filtered.columns:
|
| 271 |
+
return g_filtered[c].tolist()
|
| 272 |
+
return [None] * len(g_filtered)
|
| 273 |
+
|
| 274 |
+
mu = col_or_nan(f"{base_col}_mu_{window}")
|
| 275 |
+
p1 = col_or_nan(f"{base_col}_band_p1_{window}")
|
| 276 |
+
n1 = col_or_nan(f"{base_col}_band_n1_{window}")
|
| 277 |
+
p2 = col_or_nan(f"{base_col}_band_p2_{window}")
|
| 278 |
+
n2 = col_or_nan(f"{base_col}_band_n2_{window}")
|
| 279 |
+
p3 = col_or_nan(f"{base_col}_band_p3_{window}")
|
| 280 |
+
n3 = col_or_nan(f"{base_col}_band_n3_{window}")
|
| 281 |
+
|
| 282 |
+
# 构建图表
|
| 283 |
+
line = (
|
| 284 |
+
Line(init_opts=opts.InitOpts(width="100%", height="560px"))
|
| 285 |
+
.add_xaxis(dates)
|
| 286 |
+
.add_yaxis(
|
| 287 |
+
"主线",
|
| 288 |
+
y_value,
|
| 289 |
+
is_symbol_show=False,
|
| 290 |
+
label_opts=opts.LabelOpts(is_show=False), # 不显示标签
|
| 291 |
+
tooltip_opts=opts.TooltipOpts( # 自定义tooltip
|
| 292 |
+
trigger="axis",
|
| 293 |
+
formatter=JsCode("""
|
| 294 |
+
function(params) {
|
| 295 |
+
var date = params[0].axisValue;
|
| 296 |
+
var value = params[0].value;
|
| 297 |
+
return '日期: ' + date + '<br/>值: ' + (value ? value.toFixed(4) : 'N/A');
|
| 298 |
+
}
|
| 299 |
+
""")
|
| 300 |
+
)
|
| 301 |
+
)
|
| 302 |
+
.add_yaxis(
|
| 303 |
+
"μ",
|
| 304 |
+
mu,
|
| 305 |
+
is_symbol_show=False,
|
| 306 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 307 |
+
tooltip_opts=opts.TooltipOpts(is_show=False) # 其他线不显示tooltip
|
| 308 |
+
)
|
| 309 |
+
.add_yaxis(
|
| 310 |
+
"μ+1σ",
|
| 311 |
+
p1,
|
| 312 |
+
is_symbol_show=False,
|
| 313 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 314 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 315 |
+
)
|
| 316 |
+
.add_yaxis(
|
| 317 |
+
"μ-1σ",
|
| 318 |
+
n1,
|
| 319 |
+
is_symbol_show=False,
|
| 320 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 321 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 322 |
+
)
|
| 323 |
+
.add_yaxis(
|
| 324 |
+
"μ+2σ",
|
| 325 |
+
p2,
|
| 326 |
+
is_symbol_show=False,
|
| 327 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 328 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 329 |
+
)
|
| 330 |
+
.add_yaxis(
|
| 331 |
+
"μ-2σ",
|
| 332 |
+
n2,
|
| 333 |
+
is_symbol_show=False,
|
| 334 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 335 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 336 |
+
)
|
| 337 |
+
.add_yaxis(
|
| 338 |
+
"μ+3σ",
|
| 339 |
+
p3,
|
| 340 |
+
is_symbol_show=False,
|
| 341 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 342 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 343 |
+
)
|
| 344 |
+
.add_yaxis(
|
| 345 |
+
"μ-3σ",
|
| 346 |
+
n3,
|
| 347 |
+
is_symbol_show=False,
|
| 348 |
+
linestyle_opts=opts.LineStyleOpts(width=1),
|
| 349 |
+
tooltip_opts=opts.TooltipOpts(is_show=False)
|
| 350 |
+
)
|
| 351 |
+
.set_global_opts(
|
| 352 |
+
title_opts=opts.TitleOpts(title=f"{index_code} {metric} ({method}) - {window} 历史分位图"),
|
| 353 |
+
legend_opts=opts.LegendOpts(pos_top="5%"),
|
| 354 |
+
xaxis_opts=opts.AxisOpts(type_="category", name="日期"),
|
| 355 |
+
yaxis_opts=opts.AxisOpts(type_="value", name=metric),
|
| 356 |
+
datazoom_opts=[opts.DataZoomOpts(type_="inside"), opts.DataZoomOpts()],
|
| 357 |
+
tooltip_opts=opts.TooltipOpts(
|
| 358 |
+
trigger="axis",
|
| 359 |
+
axis_pointer_type="cross"
|
| 360 |
+
)
|
| 361 |
+
)
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# 读取 echarts.min.js 内容
|
| 365 |
+
echarts_js = (Path("./static/echarts.min.js")).read_text(encoding="utf-8")
|
| 366 |
+
|
| 367 |
+
# 生成完整 HTML
|
| 368 |
+
full_html = f"""
|
| 369 |
+
<!DOCTYPE html>
|
| 370 |
+
<html>
|
| 371 |
+
<head>
|
| 372 |
+
<meta charset="utf-8">
|
| 373 |
+
<title>{index_code} {metric} 图表</title>
|
| 374 |
+
<script>{echarts_js}</script>
|
| 375 |
+
</head>
|
| 376 |
+
<body>
|
| 377 |
+
{line.render_embed()}
|
| 378 |
+
</body>
|
| 379 |
+
</html>
|
| 380 |
+
"""
|
| 381 |
+
return HTMLResponse(full_html)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ---------------- 7) 摘要 ----------------
|
| 385 |
+
@app.get("/summary")
|
| 386 |
+
def summary(index_code: str = Query(...), metric: str = Query(...), method: str = Query(...),
|
| 387 |
+
window: str = Query(..., regex="^(3Y|10Y|Expanding)$")) -> JSONResponse:
|
| 388 |
+
base_col = KEY_TO_BASE.get((metric, method))
|
| 389 |
+
if base_col is None:
|
| 390 |
+
return JSONResponse({"error": "bad metric/method"}, status_code=400)
|
| 391 |
+
g = df[df[INDEX_COL] == index_code].copy()
|
| 392 |
+
if g.empty: return JSONResponse({"error": "no data"}, status_code=404)
|
| 393 |
+
|
| 394 |
+
last_date = g[DATE_COL].max()
|
| 395 |
+
row = g[g[DATE_COL] == last_date].iloc[0]
|
| 396 |
+
def get_safe(c):
|
| 397 |
+
if c in g.columns:
|
| 398 |
+
v = row[c]
|
| 399 |
+
try: return None if pd.isna(v) else float(v)
|
| 400 |
+
except: return None
|
| 401 |
+
return None
|
| 402 |
+
|
| 403 |
+
return JSONResponse({
|
| 404 |
+
"date": last_date.strftime("%Y-%m-%d"),
|
| 405 |
+
"value": get_safe(base_col),
|
| 406 |
+
"pct_3Y": get_safe(f"{base_col}_pct_3Y"),
|
| 407 |
+
"pct_10Y": get_safe(f"{base_col}_pct_10Y"),
|
| 408 |
+
"pct_Expanding": get_safe(f"{base_col}_pct_Expanding"),
|
| 409 |
+
"mu": get_safe(f"{base_col}_band_mu_{window}"),
|
| 410 |
+
"n1": get_safe(f"{base_col}_band_n1_{window}"),
|
| 411 |
+
"p1": get_safe(f"{base_col}_band_p1_{window}"),
|
| 412 |
+
"n2": get_safe(f"{base_col}_band_n2_{window}"),
|
| 413 |
+
"n3": get_safe(f"{base_col}_band_n3_{window}"),
|
| 414 |
+
"p2": get_safe(f"{base_col}_band_p2_{window}"),
|
| 415 |
+
"p3": get_safe(f"{base_col}_band_p3_{window}"),
|
| 416 |
+
})
|