Entelechele commited on
Commit
b1c2e5f
·
verified ·
1 Parent(s): 7e2e2b9

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +416 -0
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
+ })