Initial fundamental dashboard
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
logs/
|
||||
outputs/
|
||||
缓存/
|
||||
备份/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,56 @@
|
||||
# 商品期货基本面评分面板
|
||||
|
||||
本仓库用于根据本地 Excel 数据生成商品期货基本面评分 HTML 面板。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `基本面评分面板.html`:当前已生成的可视化面板,可直接打开查看。
|
||||
- `generate_fundamental_dashboard.py`:底层计算与 HTML 生成脚本。
|
||||
- `refresh_dashboard_server.py`:本地刷新服务,支持在页面点击“刷新数据”后重新运行生成脚本。
|
||||
- `wind数据汇总.xlsx`:Wind 导出的原始数据。
|
||||
- `钢联数据汇总.xlsx`:钢联导出的原始数据。
|
||||
- `基本面指标确认.xlsx`:各品种各维度使用的指标与公式。
|
||||
- `月差拼接数据.xlsx`、`月差/`:月差相关资料。
|
||||
|
||||
## 更新数据后重新生成
|
||||
|
||||
将新的 Excel 数据覆盖到同名文件后,在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python generate_fundamental_dashboard.py
|
||||
```
|
||||
|
||||
运行完成后打开 `基本面评分面板.html` 即可查看更新后的面板。
|
||||
|
||||
## 使用页面内“刷新数据”按钮
|
||||
|
||||
如果希望在浏览器页面中点击“刷新数据”自动重新计算,请先启动本地服务:
|
||||
|
||||
```powershell
|
||||
python refresh_dashboard_server.py
|
||||
```
|
||||
|
||||
然后用浏览器打开终端输出的地址,通常是:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8765/基本面评分面板.html
|
||||
```
|
||||
|
||||
通过该地址打开时,页面内“刷新数据”按钮会调用 `generate_fundamental_dashboard.py` 并刷新页面。
|
||||
|
||||
## Python 依赖
|
||||
|
||||
脚本依赖:
|
||||
|
||||
```text
|
||||
pandas
|
||||
numpy
|
||||
openpyxl
|
||||
```
|
||||
|
||||
如本机缺少依赖,可安装:
|
||||
|
||||
```powershell
|
||||
pip install pandas numpy openpyxl
|
||||
```
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
CONFIRM_FILE = BASE_DIR / "基本面指标确认.xlsx"
|
||||
WIND_FILE = BASE_DIR / "wind数据汇总.xlsx"
|
||||
STEEL_FILE = BASE_DIR / "钢联数据汇总.xlsx"
|
||||
OUTPUT_FILE = BASE_DIR / "基本面评分面板.html"
|
||||
|
||||
DIMENSIONS = ["利润", "产量", "库存", "需求"]
|
||||
INVERSE_DIMS = {"库存"}
|
||||
DIM_COLORS = {
|
||||
"利润": "#2563eb",
|
||||
"产量": "#dc2626",
|
||||
"库存": "#1d4ed8",
|
||||
"需求": "#0f9f6e",
|
||||
"月差": "#64748b",
|
||||
}
|
||||
DEFAULT_BAND_WINDOW = 15
|
||||
DEFAULT_BAND_K = 3.0
|
||||
DEFAULT_BAND_TRIM = 0.10
|
||||
HISTORY_START_DEFAULT = "2026-01-01"
|
||||
BOARD_MAP = {
|
||||
"黑色": ["焦煤", "焦炭", "铁矿", "螺纹", "热卷", "硅铁", "锰硅", "不锈钢"],
|
||||
"建材": ["玻璃", "纯碱", "PVC", "原木"],
|
||||
"有色": ["沪铜", "沪铝", "沪锌", "沪铅", "沪镍", "沪锡", "氧化铝", "铝合金", "工业硅", "多晶硅", "碳酸锂", "国际铜"],
|
||||
"贵金属": ["沪金", "沪银", "铂", "钯"],
|
||||
"能源": ["原油", "液化气", "燃油", "LU燃油", "沥青", "动力煤", "欧线"],
|
||||
"煤化工": ["尿素", "甲醇"],
|
||||
"油化工": ["塑料", "聚丙烯", "丙烯", "纯苯", "苯乙烯", "对二甲苯", "PTA", "乙二醇", "短纤", "瓶片", "烧碱", "丁二烯"],
|
||||
"农产品": ["玉米", "淀粉", "豆一", "豆二", "豆粕", "豆油", "菜籽油", "菜籽粕", "棕榈油", "鸡蛋", "生猪", "棉花", "白糖", "苹果", "红枣", "花生", "棉纱"],
|
||||
"软商品": ["纸浆", "双胶纸", "橡胶", "20号胶", "BR橡胶"],
|
||||
"金融": ["IF", "IC", "IM", "IH", "二年债", "五年债", "十年债", "三十年债"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metric:
|
||||
name: str
|
||||
source: str
|
||||
unit: str
|
||||
freq: str
|
||||
series: pd.Series
|
||||
|
||||
|
||||
def clean_name(value: Any) -> str:
|
||||
if pd.isna(value):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
text = text.replace("(", "(").replace(")", ")")
|
||||
text = text.replace(";", ";").replace(",", ",")
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def read_metrics(path: Path, sheet: str, name_row: int, unit_row: int, freq_row: int, data_row: int, source: str) -> list[Metric]:
|
||||
raw = pd.read_excel(path, sheet_name=sheet, header=None)
|
||||
dates = pd.to_datetime(raw.iloc[data_row:, 0], errors="coerce")
|
||||
metrics: list[Metric] = []
|
||||
for col in range(1, raw.shape[1]):
|
||||
name = clean_name(raw.iat[name_row, col])
|
||||
if not name:
|
||||
continue
|
||||
values = pd.to_numeric(raw.iloc[data_row:, col], errors="coerce")
|
||||
frame = pd.DataFrame({"date": dates, "value": values}).dropna(subset=["date", "value"])
|
||||
if frame.empty:
|
||||
continue
|
||||
series = frame.drop_duplicates("date").set_index("date")["value"].sort_index()
|
||||
if series.empty:
|
||||
continue
|
||||
metrics.append(
|
||||
Metric(
|
||||
name=name,
|
||||
source=source,
|
||||
unit=str(raw.iat[unit_row, col]) if pd.notna(raw.iat[unit_row, col]) else "",
|
||||
freq=str(raw.iat[freq_row, col]) if pd.notna(raw.iat[freq_row, col]) else "",
|
||||
series=series,
|
||||
)
|
||||
)
|
||||
return metrics
|
||||
|
||||
|
||||
def build_metric_index(metrics: list[Metric]) -> tuple[dict[str, Metric], list[str]]:
|
||||
grouped: dict[str, list[Metric]] = {}
|
||||
for metric in metrics:
|
||||
grouped.setdefault(metric.name, []).append(metric)
|
||||
selected: dict[str, Metric] = {}
|
||||
for name, items in grouped.items():
|
||||
selected[name] = max(items, key=lambda m: (m.series.index.max(), m.series.notna().sum()))
|
||||
return selected, sorted(selected.keys(), key=len, reverse=True)
|
||||
|
||||
|
||||
def normalize_expr(expr: Any) -> str:
|
||||
if pd.isna(expr):
|
||||
return ""
|
||||
text = str(expr).strip()
|
||||
text = text.replace("(", "(").replace(")", ")")
|
||||
text = text.replace(";", ";").replace(",", ",")
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = re.sub(r"(?i)\bSum\s*\(", "(", text)
|
||||
text = text.replace(";", "+")
|
||||
return text
|
||||
|
||||
|
||||
def parse_expression(expr: Any, metric_index: dict[str, Metric], metric_names: list[str]) -> tuple[str, dict[str, Metric], list[str]]:
|
||||
text = normalize_expr(expr)
|
||||
if not text:
|
||||
return "", {}, []
|
||||
used: dict[str, Metric] = {}
|
||||
token_expr = text
|
||||
for idx, name in enumerate(metric_names):
|
||||
if name and name in token_expr:
|
||||
token = f"m{idx}"
|
||||
token_expr = token_expr.replace(name, token)
|
||||
used[token] = metric_index[name]
|
||||
leftovers = re.sub(r"m\d+", " ", token_expr)
|
||||
leftovers = re.sub(r"[0-9eE\.\+\-\*/\(\)\s,]+", " ", leftovers)
|
||||
missing = [x.strip() for x in re.split(r"\s+", leftovers) if x.strip()]
|
||||
return token_expr, used, missing
|
||||
|
||||
|
||||
def eval_expression(expr: Any, metric_index: dict[str, Metric], metric_names: list[str]) -> tuple[pd.Series | None, list[str], list[Metric]]:
|
||||
token_expr, used, missing = parse_expression(expr, metric_index, metric_names)
|
||||
if not token_expr or not used:
|
||||
return None, missing, []
|
||||
local_env = {token: metric.series for token, metric in used.items()}
|
||||
try:
|
||||
value = eval(token_expr, {"__builtins__": {}}, local_env)
|
||||
except Exception:
|
||||
return None, missing or [token_expr], list(used.values())
|
||||
if isinstance(value, (int, float, np.number)):
|
||||
return None, missing, list(used.values())
|
||||
series = pd.to_numeric(value, errors="coerce").dropna().sort_index()
|
||||
return (series if not series.empty else None), missing, list(used.values())
|
||||
|
||||
|
||||
def circular_day_diff(day_values: np.ndarray, target_day: int) -> np.ndarray:
|
||||
diff = np.abs(day_values - target_day)
|
||||
return np.minimum(diff, 366 - diff)
|
||||
|
||||
|
||||
def trimmed_values(values: pd.Series, trim: float = DEFAULT_BAND_TRIM) -> pd.Series:
|
||||
values = pd.to_numeric(values, errors="coerce").dropna().sort_values()
|
||||
if values.empty or trim <= 0:
|
||||
return values
|
||||
cut = int(len(values) * trim)
|
||||
if len(values) - cut * 2 < 4:
|
||||
return values
|
||||
return values.iloc[cut : len(values) - cut]
|
||||
|
||||
|
||||
def seasonal_band_sample(history: pd.Series, day: int, window: int = DEFAULT_BAND_WINDOW) -> tuple[pd.Series, int]:
|
||||
history = pd.to_numeric(history, errors="coerce").dropna().sort_index()
|
||||
if history.empty:
|
||||
return history, window
|
||||
days = history.index.dayofyear.to_numpy()
|
||||
sample = history[circular_day_diff(days, day) <= window]
|
||||
used_window = window
|
||||
if len(sample) < 8:
|
||||
sample = history[circular_day_diff(days, day) <= 30]
|
||||
used_window = 30
|
||||
if len(sample) < 8:
|
||||
sample = history
|
||||
used_window = 366
|
||||
return sample, used_window
|
||||
|
||||
|
||||
def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool = False) -> dict[str, Any] | None:
|
||||
sample, used_window = seasonal_band_sample(history, day)
|
||||
sample = trimmed_values(sample)
|
||||
if len(sample) < 4:
|
||||
return None
|
||||
mean = float(sample.mean())
|
||||
std = float(sample.std(ddof=0))
|
||||
if not math.isfinite(std):
|
||||
std = 0.0
|
||||
low = mean - DEFAULT_BAND_K * std
|
||||
high = mean + DEFAULT_BAND_K * std
|
||||
if not math.isfinite(low) or not math.isfinite(high):
|
||||
return None
|
||||
if high == low:
|
||||
raw_score = 100.0 if latest_value >= high else 0.0
|
||||
elif latest_value >= high:
|
||||
raw_score = 100.0
|
||||
elif latest_value <= low:
|
||||
raw_score = 0.0
|
||||
else:
|
||||
raw_score = 100.0 * (latest_value - low) / (high - low)
|
||||
score = 100.0 - raw_score if inverse else raw_score
|
||||
return {
|
||||
"score": max(0.0, min(100.0, score)),
|
||||
"band_low": low,
|
||||
"band_high": high,
|
||||
"band_mean": mean,
|
||||
"band_window": used_window,
|
||||
"band_k": DEFAULT_BAND_K,
|
||||
"band_trim": DEFAULT_BAND_TRIM,
|
||||
}
|
||||
|
||||
|
||||
def seasonal_percentile(series: pd.Series) -> dict[str, Any] | None:
|
||||
series = pd.to_numeric(series, errors="coerce").dropna().sort_index()
|
||||
if series.empty:
|
||||
return None
|
||||
latest_date = series.index.max()
|
||||
latest_value = float(series.loc[latest_date])
|
||||
history = series[series.index < latest_date].dropna()
|
||||
if history.empty:
|
||||
return None
|
||||
day = int(latest_date.dayofyear)
|
||||
day_diff = np.abs(history.index.dayofyear - day)
|
||||
sample = history[day_diff <= 15]
|
||||
window = 15
|
||||
if len(sample) < 8:
|
||||
sample = history[day_diff <= 30]
|
||||
window = 30
|
||||
if len(sample) < 8:
|
||||
sample = history
|
||||
window = 366
|
||||
less = float((sample < latest_value).sum())
|
||||
equal = float((sample == latest_value).sum())
|
||||
raw_pct = 100.0 * (less + 0.5 * equal) / len(sample)
|
||||
latest_year = int(latest_date.year)
|
||||
yearly: dict[str, list[tuple[str, float]]] = {}
|
||||
clipped = series[series.index.year >= latest_year - 8]
|
||||
for dt, val in clipped.items():
|
||||
yearly.setdefault(str(int(dt.year)), []).append((dt.strftime("%m-%d"), float(val)))
|
||||
return {
|
||||
"latest_date": latest_date.strftime("%Y-%m-%d"),
|
||||
"latest_value": latest_value,
|
||||
"raw_percentile": round(raw_pct, 1),
|
||||
"sample_size": int(len(sample)),
|
||||
"window": window,
|
||||
"years": yearly,
|
||||
"chart_transform": "linear_z_score_in_browser",
|
||||
}
|
||||
|
||||
|
||||
def commodity_boards(name: str) -> list[str]:
|
||||
boards = [board for board, names in BOARD_MAP.items() if name in names]
|
||||
return boards or ["其他"]
|
||||
|
||||
|
||||
def score_series_at(series: pd.Series, date: pd.Timestamp, inverse: bool = False) -> float | None:
|
||||
series = pd.to_numeric(series, errors="coerce").dropna().sort_index()
|
||||
available = series[series.index <= date]
|
||||
if available.empty:
|
||||
return None
|
||||
value_date = available.index.max()
|
||||
value = float(available.loc[value_date])
|
||||
history = series[series.index < value_date].dropna()
|
||||
if history.empty:
|
||||
return None
|
||||
band = band_score(value, history, int(value_date.dayofyear), inverse)
|
||||
if band:
|
||||
return float(band["score"])
|
||||
sample, _ = seasonal_band_sample(history, int(value_date.dayofyear))
|
||||
if sample.empty:
|
||||
return None
|
||||
less = float((sample < value).sum())
|
||||
equal = float((sample == value).sum())
|
||||
raw = 100.0 * (less + 0.5 * equal) / len(sample)
|
||||
return 100.0 - raw if inverse else raw
|
||||
|
||||
|
||||
def build_score_history(dim_series: dict[str, pd.Series]) -> list[dict[str, Any]]:
|
||||
available = [s.dropna().sort_index() for s in dim_series.values() if s is not None and not s.dropna().empty]
|
||||
if not available:
|
||||
return []
|
||||
latest = max(s.index.max() for s in available)
|
||||
start = pd.Timestamp(HISTORY_START_DEFAULT)
|
||||
if latest < start:
|
||||
start = min(s.index.min() for s in available)
|
||||
dates = pd.date_range(start=start, end=latest, freq="D")
|
||||
history: list[dict[str, Any]] = []
|
||||
for date in dates:
|
||||
scores: dict[str, float] = {}
|
||||
for dim, series in dim_series.items():
|
||||
score = score_series_at(series, date, dim in INVERSE_DIMS)
|
||||
if score is not None and math.isfinite(score):
|
||||
scores[dim] = score
|
||||
if not scores:
|
||||
continue
|
||||
dims_with_demand = [d for d in DIMENSIONS if d in scores]
|
||||
dims_without_demand = [d for d in DIMENSIONS if d != "需求" and d in scores]
|
||||
total = float(np.mean([scores[d] for d in dims_with_demand])) if dims_with_demand else None
|
||||
total_no_demand = float(np.mean([scores[d] for d in dims_without_demand])) if dims_without_demand else None
|
||||
history.append(
|
||||
{
|
||||
"date": date.strftime("%Y-%m-%d"),
|
||||
"total": round(total, 1) if total is not None else None,
|
||||
"totalNoDemand": round(total_no_demand, 1) if total_no_demand is not None else None,
|
||||
}
|
||||
)
|
||||
return history
|
||||
|
||||
|
||||
def make_records() -> dict[str, Any]:
|
||||
metrics = []
|
||||
metrics.extend(read_metrics(WIND_FILE, "wind", 1, 3, 2, 8, "Wind"))
|
||||
metrics.extend(read_metrics(STEEL_FILE, "钢联", 1, 2, 5, 10, "钢联"))
|
||||
metric_index, metric_names = build_metric_index(metrics)
|
||||
confirm = pd.read_excel(CONFIRM_FILE).fillna("")
|
||||
commodities: list[dict[str, Any]] = []
|
||||
for idx, row in confirm.iterrows():
|
||||
name = str(row["品种"]).strip()
|
||||
if not name:
|
||||
continue
|
||||
dims: dict[str, Any] = {}
|
||||
dim_series: dict[str, pd.Series] = {}
|
||||
valid_scores: list[float] = []
|
||||
for dim in DIMENSIONS:
|
||||
series, missing, used_metrics = eval_expression(row.get(dim, ""), metric_index, metric_names)
|
||||
if series is not None:
|
||||
dim_series[dim] = series
|
||||
stat = seasonal_percentile(series) if series is not None else None
|
||||
if stat:
|
||||
latest_dt = pd.Timestamp(stat["latest_date"])
|
||||
history = series[series.index < latest_dt].dropna()
|
||||
band = band_score(stat["latest_value"], history, int(latest_dt.dayofyear), dim in INVERSE_DIMS)
|
||||
if band:
|
||||
score = band["score"]
|
||||
stat.update(band)
|
||||
else:
|
||||
raw = stat["raw_percentile"]
|
||||
score = 100.0 - raw if dim in INVERSE_DIMS else raw
|
||||
stat["score"] = round(score, 1)
|
||||
stat["direction"] = "低库存更优,按正常区间反向计分" if dim in INVERSE_DIMS else "高于正常区间更优"
|
||||
stat["expr"] = str(row.get(dim, "")).strip()
|
||||
stat["metrics"] = [
|
||||
{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq}
|
||||
for m in used_metrics
|
||||
]
|
||||
valid_scores.append(float(score))
|
||||
dims[dim] = stat or {
|
||||
"score": None,
|
||||
"raw_percentile": None,
|
||||
"direction": "缺数据",
|
||||
"expr": str(row.get(dim, "")).strip(),
|
||||
"missing": missing,
|
||||
"metrics": [{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq} for m in used_metrics],
|
||||
}
|
||||
total = round(float(np.mean(valid_scores)), 1) if valid_scores else None
|
||||
boards = commodity_boards(name)
|
||||
commodities.append(
|
||||
{
|
||||
"rank": None,
|
||||
"name": name,
|
||||
"board": " / ".join(boards),
|
||||
"boards": boards,
|
||||
"total": total,
|
||||
"valid_count": len(valid_scores),
|
||||
"dims": dims,
|
||||
"scoreHistory": build_score_history(dim_series),
|
||||
}
|
||||
)
|
||||
ranked = sorted([c for c in commodities if c["total"] is not None], key=lambda x: x["total"], reverse=True)
|
||||
for i, item in enumerate(ranked, 1):
|
||||
item["rank"] = i
|
||||
no_score = [c for c in commodities if c["total"] is None]
|
||||
commodities = ranked + no_score
|
||||
return {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"source_files": {
|
||||
"wind": WIND_FILE.name,
|
||||
"steel": STEEL_FILE.name,
|
||||
"confirm": CONFIRM_FILE.name,
|
||||
},
|
||||
"dimensions": DIMENSIONS + ["月差"],
|
||||
"boards": sorted(set(board for c in commodities for board in c.get("boards", [c["board"]]))),
|
||||
"commodities": commodities,
|
||||
}
|
||||
|
||||
|
||||
def html_template(data: dict[str, Any]) -> str:
|
||||
payload = json.dumps(data, ensure_ascii=False, allow_nan=False)
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NO.5 可更新数据 - 基本面评分面板</title>
|
||||
<style>
|
||||
:root {{
|
||||
--yellow:#fff200; --blue:#1d4ed8; --red:#dc2626; --green:#059669;
|
||||
--line:#d8dee8; --text:#172033; --muted:#667085; --bg:#f5f7fb;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin:0; font-family: "Microsoft YaHei", Arial, sans-serif; color:var(--text); background:var(--bg); }}
|
||||
.topbar {{ position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:12px; padding:7px 14px; background:var(--yellow); border-bottom:1px solid #d4c900; box-shadow:0 1px 4px rgba(0,0,0,.12); }}
|
||||
.brand {{ font-weight:800; margin-right:auto; }}
|
||||
.ctrl {{ display:flex; align-items:center; gap:6px; font-size:14px; white-space:nowrap; }}
|
||||
input, select, button {{ height:32px; border:1px solid #b9c3d4; border-radius:4px; background:white; padding:0 10px; font-size:14px; }}
|
||||
input {{ width:340px; }}
|
||||
input.mini {{ width:64px; }}
|
||||
input[type="checkbox"] {{ width:16px; height:16px; padding:0; }}
|
||||
button {{ cursor:pointer; font-weight:700; color:#475467; }}
|
||||
.tabbtn.active {{ color:#1d4ed8; border-color:#1d4ed8; }}
|
||||
.stamp {{ margin-left:auto; font-size:13px; white-space:nowrap; }}
|
||||
.summary {{ display:grid; grid-template-columns:280px 1fr 1fr; gap:12px; padding:12px 14px 4px; }}
|
||||
.card {{ background:white; border:1px solid var(--line); border-radius:7px; padding:12px; box-shadow:0 1px 5px rgba(16,24,40,.05); }}
|
||||
.kpi-title {{ font-weight:800; color:#344054; margin-bottom:5px; }}
|
||||
.kpi-number {{ font-size:42px; line-height:1; font-weight:900; color:#111827; }}
|
||||
.hint {{ color:var(--muted); font-size:12px; line-height:1.55; }}
|
||||
.chips {{ display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; }}
|
||||
.chip {{ border:1px solid #4c84ff; color:#1d4ed8; border-radius:4px; padding:6px 8px; min-width:160px; display:flex; justify-content:space-between; font-size:13px; font-weight:700; background:#f8fbff; }}
|
||||
.chip.red {{ border-color:#ef4444; color:#dc2626; background:#fffafa; }}
|
||||
.chip.good {{ border-color:#22c55e; color:#047857; background:#f6fffa; }}
|
||||
.note {{ padding:4px 14px 10px; color:#64748b; font-size:12px; }}
|
||||
.rows {{ margin:0 14px 24px; background:white; border:1px solid var(--line); }}
|
||||
.history-view {{ display:none; margin:0 14px 24px; background:white; border:1px solid var(--line); padding:12px; }}
|
||||
.history-view.active {{ display:block; }}
|
||||
.history-tools {{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px; }}
|
||||
.history-tools input {{ width:150px; }}
|
||||
.history-legend {{ display:flex; flex-wrap:wrap; gap:5px 10px; max-height:92px; overflow:auto; padding:6px 0 8px; border-top:1px solid var(--line); }}
|
||||
.history-legend-item {{ display:inline-flex; align-items:center; gap:4px; min-width:70px; font-size:12px; color:#475467; cursor:pointer; user-select:none; }}
|
||||
.history-legend-item::before {{ content:""; width:18px; height:2px; background:var(--c); display:inline-block; }}
|
||||
.history-legend-item.active {{ font-weight:900; color:#111827; }}
|
||||
.history-chart-wrap {{ position:relative; border-top:1px solid var(--line); }}
|
||||
#historySvg {{ height:620px; }}
|
||||
.history-line {{ cursor:pointer; transition:opacity .12s ease, stroke-width .12s ease; }}
|
||||
.history-tooltip {{ position:absolute; display:none; min-width:260px; max-width:760px; padding:8px 10px; border:1px solid #cfd6e0; border-radius:6px; background:rgba(255,255,255,.96); box-shadow:0 8px 22px rgba(15,23,42,.15); font-size:12px; color:#344054; pointer-events:none; z-index:3; }}
|
||||
.history-tooltip .date {{ font-weight:900; color:#111827; margin-bottom:5px; }}
|
||||
.history-tooltip .grid {{ display:grid; grid-template-columns:repeat(3, minmax(150px, 1fr)); gap:1px 12px; }}
|
||||
.history-tooltip .row {{ display:flex; justify-content:space-between; gap:12px; line-height:1.45; white-space:nowrap; }}
|
||||
.history-tooltip .name {{ color:var(--c); font-weight:700; }}
|
||||
.commodity {{ display:grid; grid-template-columns:48px minmax(0,1fr); border-top:1px solid var(--line); }}
|
||||
.commodity:first-child {{ border-top:0; }}
|
||||
.side {{ display:flex; align-items:center; justify-content:center; writing-mode:vertical-rl; font-weight:900; color:#12356b; background:#fbfdff; border-right:1px solid var(--line); letter-spacing:2px; }}
|
||||
.body {{ min-width:0; }}
|
||||
.scoreline {{ display:grid; grid-template-columns:160px repeat(5, minmax(130px, 1fr)); align-items:stretch; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line); background:#fbfcff; }}
|
||||
.total {{ display:flex; align-items:center; gap:8px; font-weight:900; color:var(--blue); white-space:nowrap; }}
|
||||
.total .num {{ font-size:28px; }}
|
||||
.dimbox {{ background:white; border-left:4px solid var(--blue); padding:5px 8px; min-height:42px; }}
|
||||
.dimbox.bad {{ opacity:.65; border-left-color:#94a3b8; }}
|
||||
.dimname {{ font-size:13px; color:#475467; font-weight:800; display:flex; justify-content:space-between; }}
|
||||
.dimscore {{ font-size:13px; font-weight:900; color:#1d4ed8; }}
|
||||
.dimhint {{ font-size:12px; color:#667085; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }}
|
||||
.detail {{ padding:0 10px 8px; color:#667085; font-size:12px; }}
|
||||
.charts {{ display:grid; grid-template-columns:repeat(4, minmax(260px,1fr)); }}
|
||||
.chartcell {{ min-width:0; border-top:1px solid var(--line); border-right:1px solid var(--line); padding:8px 10px 10px; }}
|
||||
.chartcell:nth-child(4n) {{ border-right:0; }}
|
||||
.chart-title {{ text-align:center; font-size:18px; color:#475467; margin:2px 0 4px; }}
|
||||
.metric-title {{ color:#667085; font-size:12px; min-height:32px; line-height:1.35; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }}
|
||||
svg {{ width:100%; height:230px; display:block; }}
|
||||
.axis {{ stroke:#cfd6e0; stroke-width:1; }}
|
||||
.grid {{ stroke:#e5e7eb; stroke-width:1; }}
|
||||
.legend {{ display:flex; flex-wrap:wrap; justify-content:center; gap:8px; min-height:21px; font-size:12px; color:#475467; }}
|
||||
.legend span::before {{ content:""; display:inline-block; width:18px; height:2px; margin-right:4px; vertical-align:middle; background:var(--c); }}
|
||||
.empty {{ padding:24px; text-align:center; color:#667085; }}
|
||||
@media (max-width: 1200px) {{
|
||||
.summary {{ grid-template-columns:1fr; }}
|
||||
.scoreline {{ grid-template-columns:1fr 1fr 1fr; }}
|
||||
.charts {{ grid-template-columns:1fr 1fr; }}
|
||||
input {{ width:220px; }}
|
||||
}}
|
||||
@media (max-width: 760px) {{
|
||||
.topbar {{ flex-wrap:wrap; }}
|
||||
.stamp {{ margin-left:0; }}
|
||||
.scoreline {{ grid-template-columns:1fr; }}
|
||||
.charts {{ grid-template-columns:1fr; }}
|
||||
.commodity {{ grid-template-columns:34px minmax(0,1fr); }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="brand">NO.5 可更新数据</div>
|
||||
<label class="ctrl">品种 <input id="search" placeholder="搜索品种或指标"></label>
|
||||
<label class="ctrl">维度 <select id="dim"><option>全部</option></select></label>
|
||||
<label class="ctrl">板块 <select id="board"><option>全部</option></select></label>
|
||||
<label class="ctrl">窗口 <input class="mini" id="bandWindow" type="number" min="3" max="90" step="1" value="15"></label>
|
||||
<label class="ctrl">倍数 <input class="mini" id="bandK" type="number" min="0.5" max="6" step="0.5" value="3"></label>
|
||||
<label class="ctrl">去尾 <input class="mini" id="bandTrim" type="number" min="0" max="40" step="5" value="10"></label>
|
||||
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
|
||||
<button class="tabbtn active" id="dashboardTab">面板</button>
|
||||
<button class="tabbtn" id="historyTab">评分走势</button>
|
||||
<button id="refreshData">刷新数据</button>
|
||||
<button id="reset">重置</button>
|
||||
<div class="stamp">核心修正:库存反向计分 生成时间:<span id="generated"></span></div>
|
||||
</div>
|
||||
<section class="summary">
|
||||
<div class="card">
|
||||
<div class="kpi-title">基本面总分</div>
|
||||
<div class="kpi-number" id="avgScore">--</div>
|
||||
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种;月差当前暂无映射,未计入总分。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="kpi-title">基本面较强</div>
|
||||
<div class="chips" id="strong"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="kpi-title">基本面较弱</div>
|
||||
<div class="chips" id="weak"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="note">维度得分按同期正常波动区间计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。阴影带参数只影响图表展示,总分使用默认 ±15 天、3 倍标准差、去尾 10%。</div>
|
||||
<main class="rows" id="rows"></main>
|
||||
<section class="history-view" id="historyView">
|
||||
<div class="history-tools">
|
||||
<strong>各品种基本面得分变化</strong>
|
||||
<label class="ctrl">开始 <input id="historyStart" type="date" value="2026-01-01"></label>
|
||||
<label class="ctrl">结束 <input id="historyEnd" type="date"></label>
|
||||
</div>
|
||||
<div class="history-legend" id="historyLegend"></div>
|
||||
<div class="history-chart-wrap" id="historyChartWrap">
|
||||
<svg id="historySvg" viewBox="0 0 1600 620" preserveAspectRatio="none"></svg>
|
||||
<div class="history-tooltip" id="historyTooltip"></div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
const DATA = {payload};
|
||||
const OLDER_COLORS = ["#1976d2","#65a30d","#a21caf","#9ca3af","#0f9f9f","#374151","#7c3aed"];
|
||||
function yearColor(year, years) {{
|
||||
const ordered = Object.keys(years).map(Number).sort((a,b)=>b-a);
|
||||
const rank = ordered.indexOf(Number(year));
|
||||
if (rank === 0) return "#ef4444";
|
||||
if (rank === 1) return "#f97316";
|
||||
return OLDER_COLORS[(rank - 2 + OLDER_COLORS.length) % OLDER_COLORS.length];
|
||||
}}
|
||||
const dimColors = {json.dumps(DIM_COLORS, ensure_ascii=False)};
|
||||
const dims = DATA.dimensions;
|
||||
const search = document.getElementById("search");
|
||||
const dimSel = document.getElementById("dim");
|
||||
const boardSel = document.getElementById("board");
|
||||
const bandWindow = document.getElementById("bandWindow");
|
||||
const bandK = document.getElementById("bandK");
|
||||
const bandTrim = document.getElementById("bandTrim");
|
||||
const includeDemand = document.getElementById("includeDemand");
|
||||
const dashboardTab = document.getElementById("dashboardTab");
|
||||
const historyTab = document.getElementById("historyTab");
|
||||
const rowsEl = document.getElementById("rows");
|
||||
const historyView = document.getElementById("historyView");
|
||||
const historyStart = document.getElementById("historyStart");
|
||||
const historyEnd = document.getElementById("historyEnd");
|
||||
const historyLegend = document.getElementById("historyLegend");
|
||||
const historyTooltip = document.getElementById("historyTooltip");
|
||||
const historyChartWrap = document.getElementById("historyChartWrap");
|
||||
const refreshData = document.getElementById("refreshData");
|
||||
for (const d of dims) dimSel.append(new Option(d, d));
|
||||
for (const b of DATA.boards) boardSel.append(new Option(b, b));
|
||||
document.getElementById("generated").textContent = DATA.generated_at;
|
||||
function todayString() {{
|
||||
const d = new Date();
|
||||
return `${{d.getFullYear()}}-${{String(d.getMonth()+1).padStart(2,"0")}}-${{String(d.getDate()).padStart(2,"0")}}`;
|
||||
}}
|
||||
function majorityHistoryEndDate() {{
|
||||
const counts = new Map();
|
||||
for (const c of DATA.commodities) {{
|
||||
const last = (c.scoreHistory || []).map(p => p.date).sort().at(-1);
|
||||
if (last) counts.set(last, (counts.get(last) || 0) + 1);
|
||||
}}
|
||||
let best = "", bestCount = -1;
|
||||
for (const [date, count] of counts) {{
|
||||
if (count > bestCount || (count === bestCount && date > best)) {{
|
||||
best = date; bestCount = count;
|
||||
}}
|
||||
}}
|
||||
return best || todayString();
|
||||
}}
|
||||
historyEnd.value = majorityHistoryEndDate();
|
||||
let activeView = "dashboard";
|
||||
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; includeDemand.checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
|
||||
dashboardTab.onclick = () => {{ activeView="dashboard"; dashboardTab.classList.add("active"); historyTab.classList.remove("active"); render(); }};
|
||||
historyTab.onclick = () => {{ activeView="history"; historyTab.classList.add("active"); dashboardTab.classList.remove("active"); render(); }};
|
||||
refreshData.onclick = refreshFromLocalProgram;
|
||||
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = includeDemand.onchange = historyStart.onchange = historyEnd.onchange = render;
|
||||
|
||||
function fmt(v, n=1) {{ return v == null || Number.isNaN(v) ? "--" : Number(v).toFixed(n); }}
|
||||
function matches(c) {{
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const dim = dimSel.value;
|
||||
const board = boardSel.value;
|
||||
if (board !== "全部" && !(c.boards || [c.board]).includes(board)) return false;
|
||||
if (dim !== "全部" && dim !== "月差" && !c.dims[dim]?.score && c.dims[dim]?.score !== 0) return false;
|
||||
if (!q) return true;
|
||||
const hay = [c.name, c.board, ...Object.values(c.dims).map(d => [d.expr, ...(d.metrics||[]).map(m=>m.name)].join(" "))].join(" ").toLowerCase();
|
||||
return hay.includes(q);
|
||||
}}
|
||||
function activeDims() {{ return includeDemand.checked ? ["利润","产量","库存","需求"] : ["利润","产量","库存"]; }}
|
||||
function computeTotal(c) {{
|
||||
const keys = activeDims();
|
||||
const values = keys.map(d => c.dims[d]?.score).filter(v => v != null && Number.isFinite(Number(v))).map(Number);
|
||||
return {{total: values.length ? values.reduce((s,v)=>s+v,0)/values.length : null, valid: values.length, denom: keys.length}};
|
||||
}}
|
||||
function rankedList(list) {{
|
||||
const mapped = list.map(c => Object.assign({{}}, c, computeTotal(c)));
|
||||
const scored = mapped.filter(c => c.total != null).sort((a,b)=>b.total-a.total);
|
||||
scored.forEach((c,i)=>c.displayRank=i+1);
|
||||
return scored.concat(mapped.filter(c => c.total == null));
|
||||
}}
|
||||
function topChips(list, reverse=false) {{
|
||||
return list.slice().sort((a,b)=> reverse ? a.total-b.total : b.total-a.total).slice(0,6).map(c => `<div class="chip ${{reverse?"red":"good"}}"><span>${{c.displayRank||"-"}}. ${{esc(c.name)}}</span><span>${{fmt(c.total)}}</span></div>`).join("");
|
||||
}}
|
||||
function esc(s) {{ return String(s ?? "").replace(/[&<>"']/g, m => ({{"&":"&","<":"<",">":">",'"':""","'":"'"}}[m])); }}
|
||||
function render() {{
|
||||
const list = rankedList(DATA.commodities.filter(matches));
|
||||
const scored = list.filter(c => c.total != null);
|
||||
document.getElementById("avgScore").textContent = scored.length ? fmt(scored.reduce((s,c)=>s+c.total,0)/scored.length) : "--";
|
||||
document.getElementById("coverage").textContent = `当前筛选覆盖 ${{scored.length}} 个有可用数据的品种;${{includeDemand.checked ? "需求参与总分" : "需求不参与总分"}};月差当前暂无映射。`;
|
||||
document.getElementById("strong").innerHTML = topChips(scored, false) || `<span class="hint">暂无可用数据</span>`;
|
||||
document.getElementById("weak").innerHTML = topChips(scored, true) || `<span class="hint">暂无可用数据</span>`;
|
||||
rowsEl.style.display = activeView === "dashboard" ? "block" : "none";
|
||||
historyView.classList.toggle("active", activeView === "history");
|
||||
rowsEl.innerHTML = list.length ? list.map(renderCommodity).join("") : `<div class="empty">没有匹配的品种</div>`;
|
||||
for (const cell of document.querySelectorAll("[data-chart]")) {{
|
||||
try {{
|
||||
drawChart(cell, JSON.parse(cell.dataset.chart));
|
||||
}} catch (err) {{
|
||||
cell.innerHTML = `<text x="260" y="115" text-anchor="middle" font-size="14" fill="#667085">绘图参数异常</text>`;
|
||||
}}
|
||||
}}
|
||||
if (activeView === "history") drawHistoryChart(list.filter(c => c.total != null));
|
||||
}}
|
||||
function renderCommodity(c) {{
|
||||
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v)), renderMonthSpread()].join("");
|
||||
const chartDims = dimSel.value === "全部" || dimSel.value === "月差" ? ["利润","产量","库存","需求"] : [dimSel.value];
|
||||
const charts = chartDims.filter(d => d !== "月差").map(d => renderChartCell(c, d, c.dims[d])).join("");
|
||||
return `<section class="commodity">
|
||||
<div class="side">${{esc(c.name)}}</div>
|
||||
<div class="body">
|
||||
<div class="scoreline"><div class="total"><span>#${{c.displayRank||"-"}}</span><span class="num">${{fmt(c.total)}}</span><span>总分</span></div>${{dimCards}}</div>
|
||||
<div class="detail">季节性口径:最新值相对同期正常区间;库存反向,其余正向。有效维度:${{c.valid}}/${{c.denom}} 板块:${{esc(c.board)}}</div>
|
||||
<div class="charts">${{charts}}</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}}
|
||||
function renderDim(dim, v) {{
|
||||
const color = dimColors[dim] || "#64748b";
|
||||
const ok = v && v.score != null;
|
||||
return `<div class="dimbox ${{ok?"":"bad"}}" style="border-left-color:${{color}}">
|
||||
<div class="dimname"><span>${{esc(dim)}}</span><span class="dimscore" style="color:${{color}}">${{fmt(v?.score)}}</span></div>
|
||||
<div class="dimhint">${{esc(ok ? v.direction : "缺数据/未配置")}}</div>
|
||||
</div>`;
|
||||
}}
|
||||
function renderMonthSpread() {{
|
||||
return `<div class="dimbox bad" style="border-left-color:#94a3b8"><div class="dimname"><span>月差</span><span class="dimscore">--</span></div><div class="dimhint">未配置</div></div>`;
|
||||
}}
|
||||
function renderChartCell(c, dim, v) {{
|
||||
const title = `${{esc(c.name)}}${{esc(dim)}}`;
|
||||
if (!v || !v.years) return `<div class="chartcell"><div class="chart-title">${{title}}</div><div class="metric-title">${{esc(v?.expr || "未配置")}}</div><div class="empty">缺少可绘制数据</div></div>`;
|
||||
return `<div class="chartcell">
|
||||
<div class="chart-title">${{title}}</div>
|
||||
<div class="metric-title">${{esc(v.expr)}}</div>
|
||||
<div class="legend">${{Object.keys(v.years).map(y=>`<span style="--c:${{yearColor(y, v.years)}}">${{y}}</span>`).join("")}}</div>
|
||||
<svg data-chart='${{esc(JSON.stringify(v.years))}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
|
||||
</div>`;
|
||||
}}
|
||||
function parseDay(md) {{
|
||||
const [m,d] = md.split("-").map(Number);
|
||||
const date = new Date(2024, m-1, d);
|
||||
const start = new Date(2024,0,1);
|
||||
return Math.round((date-start)/86400000)+1;
|
||||
}}
|
||||
function chartParams() {{
|
||||
return {{
|
||||
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
|
||||
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
|
||||
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
|
||||
}};
|
||||
}}
|
||||
function mean(arr) {{ return arr.reduce((s,v)=>s+v,0) / arr.length; }}
|
||||
function std(arr) {{
|
||||
if (!arr.length) return 0;
|
||||
const m = mean(arr);
|
||||
return Math.sqrt(arr.reduce((s,v)=>s+(v-m)*(v-m),0) / arr.length);
|
||||
}}
|
||||
function circularDiff(a, b) {{
|
||||
const d = Math.abs(a - b);
|
||||
return Math.min(d, 366 - d);
|
||||
}}
|
||||
function trimmed(arr, trim) {{
|
||||
const sorted = arr.filter(Number.isFinite).sort((a,b)=>a-b);
|
||||
if (!sorted.length || trim <= 0) return sorted;
|
||||
const cut = Math.floor(sorted.length * trim);
|
||||
if (sorted.length - cut * 2 < 4) return sorted;
|
||||
return sorted.slice(cut, sorted.length - cut);
|
||||
}}
|
||||
function smoothBand(band, radius) {{
|
||||
if (!band.length || radius <= 0) return band;
|
||||
const n = band.length;
|
||||
radius = Math.min(radius, Math.max(0, Math.floor((n - 1) / 2)));
|
||||
if (radius <= 0) return band;
|
||||
const smoothed = band.map((p, i) => {{
|
||||
let low = 0, high = 0, mid = 0, wsum = 0;
|
||||
for (let offset = -radius; offset <= radius; offset++) {{
|
||||
const idx = ((i + offset) % n + n) % n;
|
||||
const weight = radius + 1 - Math.abs(offset);
|
||||
low += band[idx][1] * weight;
|
||||
high += band[idx][2] * weight;
|
||||
mid += band[idx][3] * weight;
|
||||
wsum += weight;
|
||||
}}
|
||||
low /= wsum; high /= wsum; mid /= wsum;
|
||||
if (low > high) {{
|
||||
const tmp = low; low = high; high = tmp;
|
||||
}}
|
||||
mid = Math.max(low, Math.min(high, mid));
|
||||
return [p[0], low, high, mid];
|
||||
}});
|
||||
return smoothed;
|
||||
}}
|
||||
function buildNormalizedChart(years) {{
|
||||
const params = chartParams();
|
||||
const rawPoints = [];
|
||||
Object.entries(years).forEach(([year, pts]) => {{
|
||||
pts.forEach(p => {{
|
||||
const value = Number(p[1]);
|
||||
if (Number.isFinite(value)) rawPoints.push({{year, md:p[0], day:parseDay(p[0]), value}});
|
||||
}});
|
||||
}});
|
||||
const rawValues = rawPoints.map(p=>p.value);
|
||||
if (!rawValues.length) return {{years:{{}}, band:[]}};
|
||||
const m = mean(rawValues);
|
||||
let s = std(rawValues);
|
||||
if (!Number.isFinite(s) || s === 0) s = 1;
|
||||
const z = v => (v - m) / s;
|
||||
const normYears = {{}};
|
||||
Object.entries(years).forEach(([year, pts]) => {{
|
||||
normYears[year] = pts.map(p => [p[0], z(Number(p[1]))]).filter(p => Number.isFinite(p[1]));
|
||||
}});
|
||||
const band = [];
|
||||
for (let day=1; day<=366; day++) {{
|
||||
const samples = trimmed(rawPoints.filter(p => circularDiff(p.day, day) <= params.window).map(p=>p.value), params.trim);
|
||||
if (samples.length < 4) continue;
|
||||
const bm = mean(samples);
|
||||
const bs = std(samples);
|
||||
const date = new Date(2024, 0, day);
|
||||
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
|
||||
band.push([md, z(bm - params.k * bs), z(bm + params.k * bs), z(bm)]);
|
||||
}}
|
||||
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
|
||||
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
|
||||
}}
|
||||
function drawChart(svg, years) {{
|
||||
const normalized = buildNormalizedChart(years);
|
||||
years = normalized.years;
|
||||
const band = normalized.band;
|
||||
const W=520,H=230,L=44,R=8,T=16,B=30;
|
||||
const bandValues = band.flatMap(p => [p[1], p[2]]).filter(Number.isFinite);
|
||||
const values = Object.values(years).flat().map(p=>p[1]).filter(Number.isFinite).concat(bandValues);
|
||||
if (!values.length) return;
|
||||
let min=Math.min(...values), max=Math.max(...values);
|
||||
if (min===max) {{ min-=1; max+=1; }}
|
||||
const pad=(max-min)*0.12; min-=pad; max+=pad;
|
||||
const x=d=> L + (parseDay(d)-1)/365*(W-L-R);
|
||||
const y=v=> T + (max-v)/(max-min)*(H-T-B);
|
||||
let out = "";
|
||||
for (let i=0;i<=4;i++) {{
|
||||
const yy=T+i*(H-T-B)/4, val=max-i*(max-min)/4;
|
||||
out += `<line class="grid" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="4" y="${{yy+4}}" font-size="12" fill="#667085">${{val.toFixed(1)}}</text>`;
|
||||
}}
|
||||
for (const [label, day] of [["1/1",1],["4/1",92],["7/1",183],["10/1",275]]) {{
|
||||
const xx=L+(day-1)/365*(W-L-R);
|
||||
out += `<line class="grid" x1="${{xx}}" y1="${{T}}" x2="${{xx}}" y2="${{H-B}}"></line><text x="${{xx-10}}" y="${{H-9}}" font-size="12" fill="#667085">${{label}}</text>`;
|
||||
}}
|
||||
out += `<line class="axis" x1="${{L}}" y1="${{H-B}}" x2="${{W-R}}" y2="${{H-B}}"></line><line class="axis" x1="${{L}}" y1="${{T}}" x2="${{L}}" y2="${{H-B}}"></line>`;
|
||||
if (band.length) {{
|
||||
const sortedBand = band.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
|
||||
const upper = sortedBand.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[2]).toFixed(1)}}`).join(" ");
|
||||
const lower = sortedBand.slice().reverse().map(p=>`L${{x(p[0]).toFixed(1)}},${{y(p[1]).toFixed(1)}}`).join(" ");
|
||||
out += `<path d="${{upper}} ${{lower}} Z" fill="#94a3b8" opacity="0.18"></path>`;
|
||||
const mid = sortedBand.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[3]).toFixed(1)}}`).join(" ");
|
||||
out += `<path d="${{mid}}" fill="none" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="4 4" opacity="0.7" vector-effect="non-scaling-stroke"></path>`;
|
||||
}}
|
||||
Object.entries(years).forEach(([year, pts]) => {{
|
||||
const color=yearColor(year, years);
|
||||
const sorted=pts.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
|
||||
const d=sorted.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[1]).toFixed(1)}}`).join(" ");
|
||||
out += `<path d="${{d}}" fill="none" stroke="${{color}}" stroke-width="2.2" vector-effect="non-scaling-stroke"></path>`;
|
||||
}});
|
||||
svg.innerHTML = out;
|
||||
}}
|
||||
function refreshFromLocalProgram() {{
|
||||
const command = `& 'C:\\Users\\cjn_w\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe' generate_fundamental_dashboard.py`;
|
||||
fetch("http://127.0.0.1:8765/refresh", {{method:"POST"}})
|
||||
.then(r => r.ok ? r.text() : Promise.reject(new Error("refresh service unavailable")))
|
||||
.then(() => location.reload())
|
||||
.catch(async () => {{
|
||||
try {{ await navigator.clipboard.writeText(command); }} catch (e) {{}}
|
||||
alert("浏览器不能直接运行本地 Python。已尝试复制刷新命令;若启用本地刷新服务,此按钮会自动运行底层计算并刷新页面。");
|
||||
}});
|
||||
}}
|
||||
function historyColor(i) {{
|
||||
const palette = ["#ef4444","#f97316","#1976d2","#65a30d","#a21caf","#0f9f9f","#374151","#9ca3af","#7c3aed","#0891b2","#be123c","#4d7c0f"];
|
||||
return palette[i % palette.length];
|
||||
}}
|
||||
function drawHistoryChart(list) {{
|
||||
const svg = document.getElementById("historySvg");
|
||||
historyTooltip.style.display = "none";
|
||||
const start = historyStart.value || "2026-01-01";
|
||||
const end = historyEnd.value || todayString();
|
||||
const useDemand = includeDemand.checked;
|
||||
const series = list.map(c => {{
|
||||
const pts = (c.scoreHistory || [])
|
||||
.filter(p => p.date >= start && p.date <= end)
|
||||
.map(p => [p.date, useDemand ? p.total : p.totalNoDemand])
|
||||
.filter(p => p[1] != null && Number.isFinite(Number(p[1])));
|
||||
return {{name:c.name, points:pts}};
|
||||
}}).filter(s => s.points.length >= 2);
|
||||
const W=1600,H=620,L=54,R=18,T=28,B=42;
|
||||
if (!series.length) {{
|
||||
svg.innerHTML = `<text x="800" y="300" text-anchor="middle" font-size="18" fill="#667085">暂无可绘制数据</text>`;
|
||||
historyLegend.innerHTML = "";
|
||||
return;
|
||||
}}
|
||||
const minDate = new Date(start).getTime();
|
||||
const maxDate = new Date(end).getTime();
|
||||
const span = Math.max(1, maxDate - minDate);
|
||||
const x = d => L + (new Date(d).getTime() - minDate) / span * (W-L-R);
|
||||
const y = v => T + (100 - v) / 100 * (H-T-B);
|
||||
let out = "";
|
||||
for (let i=0;i<=5;i++) {{
|
||||
const val = i * 20, yy = y(val);
|
||||
out += `<line class="grid" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="8" y="${{yy+4}}" font-size="12" fill="#667085">${{val}}</text>`;
|
||||
}}
|
||||
const ticks = 6;
|
||||
for (let i=0;i<=ticks;i++) {{
|
||||
const t = minDate + span * i / ticks;
|
||||
const d = new Date(t);
|
||||
const label = `${{d.getFullYear()}}-${{String(d.getMonth()+1).padStart(2,"0")}}`;
|
||||
const xx = L + (W-L-R) * i / ticks;
|
||||
out += `<line class="grid" x1="${{xx}}" y1="${{T}}" x2="${{xx}}" y2="${{H-B}}"></line><text x="${{xx-22}}" y="${{H-12}}" font-size="12" fill="#667085">${{label}}</text>`;
|
||||
}}
|
||||
out += `<line class="axis" x1="${{L}}" y1="${{H-B}}" x2="${{W-R}}" y2="${{H-B}}"></line><line class="axis" x1="${{L}}" y1="${{T}}" x2="${{L}}" y2="${{H-B}}"></line>`;
|
||||
series.forEach((s,i) => {{
|
||||
const d = s.points.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(Number(p[1])).toFixed(1)}}`).join(" ");
|
||||
out += `<path class="history-line" data-i="${{i}}" d="${{d}}" fill="none" stroke="${{historyColor(i)}}" stroke-width="1.6" opacity="0.68" vector-effect="non-scaling-stroke"><title>${{esc(s.name)}}</title></path>`;
|
||||
}});
|
||||
out += `<g class="history-crosshair" style="display:none"><line id="historyGuideX" x1="${{L}}" y1="${{T}}" x2="${{L}}" y2="${{H-B}}" stroke="#64748b" stroke-width="1" stroke-dasharray="4 4" opacity="0.75" vector-effect="non-scaling-stroke"></line><line id="historyGuideY" x1="${{L}}" y1="${{T}}" x2="${{W-R}}" y2="${{T}}" stroke="#64748b" stroke-width="1" stroke-dasharray="4 4" opacity="0.75" vector-effect="non-scaling-stroke"></line></g>`;
|
||||
svg.innerHTML = out;
|
||||
historyLegend.innerHTML = series.map((s,i)=>`<span class="history-legend-item" data-i="${{i}}" style="--c:${{historyColor(i)}}">${{esc(s.name)}}</span>`).join("");
|
||||
|
||||
function setHighlight(idx) {{
|
||||
svg.querySelectorAll(".history-line").forEach(path => {{
|
||||
const active = idx == null || Number(path.dataset.i) === idx;
|
||||
path.setAttribute("opacity", active ? "0.95" : "0.12");
|
||||
path.setAttribute("stroke-width", active ? "3.2" : "1.1");
|
||||
}});
|
||||
historyLegend.querySelectorAll(".history-legend-item").forEach(item => item.classList.toggle("active", idx != null && Number(item.dataset.i) === idx));
|
||||
}}
|
||||
historyLegend.querySelectorAll(".history-legend-item").forEach(item => {{
|
||||
item.onmouseenter = () => setHighlight(Number(item.dataset.i));
|
||||
item.onmouseleave = () => setHighlight(null);
|
||||
}});
|
||||
svg.querySelectorAll(".history-line").forEach(path => {{
|
||||
path.onmouseenter = () => setHighlight(Number(path.dataset.i));
|
||||
path.onmouseleave = () => setHighlight(null);
|
||||
}});
|
||||
const crosshair = svg.querySelector(".history-crosshair");
|
||||
const guideX = svg.querySelector("#historyGuideX");
|
||||
const guideY = svg.querySelector("#historyGuideY");
|
||||
|
||||
function nearestPoint(points, targetTime) {{
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const p of points) {{
|
||||
const dist = Math.abs(new Date(p[0]).getTime() - targetTime);
|
||||
if (dist < bestDist) {{ best = p; bestDist = dist; }}
|
||||
}}
|
||||
return best;
|
||||
}}
|
||||
svg.onmousemove = ev => {{
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const px = (ev.clientX - rect.left) / rect.width * W;
|
||||
const clamped = Math.max(L, Math.min(W-R, px));
|
||||
const targetTime = minDate + (clamped - L) / (W-L-R) * span;
|
||||
const date = new Date(targetTime);
|
||||
const dateText = `${{date.getFullYear()}}-${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
|
||||
const rows = series.map((s,i) => {{
|
||||
const p = nearestPoint(s.points, targetTime);
|
||||
return p ? {{name:s.name, score:Number(p[1]), color:historyColor(i), date:p[0]}} : null;
|
||||
}}).filter(Boolean).sort((a,b)=>b.score-a.score);
|
||||
const py = (ev.clientY - rect.top) / rect.height * H;
|
||||
const guideYValue = Math.max(0, Math.min(100, 100 - ((py - T) / (H - T - B)) * 100));
|
||||
const nearest = rows.length ? rows.reduce((best, r) => Math.abs(Number(r.score) - guideYValue) < Math.abs(Number(best.score) - guideYValue) ? r : best, rows[0]) : null;
|
||||
if (crosshair) {{
|
||||
crosshair.style.display = "block";
|
||||
guideX.setAttribute("x1", clamped.toFixed(1));
|
||||
guideX.setAttribute("x2", clamped.toFixed(1));
|
||||
const gy = y(guideYValue);
|
||||
guideY.setAttribute("y1", gy.toFixed(1));
|
||||
guideY.setAttribute("y2", gy.toFixed(1));
|
||||
}}
|
||||
historyTooltip.innerHTML = `<div class="date">${{dateText}} 光标得分 ${{fmt(guideYValue)}}${{nearest ? " 近线 " + esc(nearest.name) + " " + fmt(nearest.score) : ""}}</div><div class="grid">` + rows.map(r => `<div class="row"><span class="name" style="--c:${{r.color}}; color:${{r.color}}">${{esc(r.name)}}</span><span>${{fmt(r.score)}}</span></div>`).join("") + `</div>`;
|
||||
const wrap = historyChartWrap.getBoundingClientRect();
|
||||
let left = ev.clientX - wrap.left + 14;
|
||||
let top = ev.clientY - wrap.top + 14;
|
||||
historyTooltip.style.display = "block";
|
||||
const tw = historyTooltip.offsetWidth || 260;
|
||||
const th = historyTooltip.offsetHeight || 360;
|
||||
if (left + tw > wrap.width) left = ev.clientX - wrap.left - tw - 14;
|
||||
if (top + th > wrap.height) top = Math.max(8, wrap.height - th - 8);
|
||||
historyTooltip.style.left = `${{Math.max(8,left)}}px`;
|
||||
historyTooltip.style.top = `${{Math.max(8,top)}}px`;
|
||||
}};
|
||||
svg.onmouseleave = () => {{
|
||||
historyTooltip.style.display = "none";
|
||||
if (crosshair) crosshair.style.display = "none";
|
||||
setHighlight(null);
|
||||
}};
|
||||
}}
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = make_records()
|
||||
OUTPUT_FILE.write_text(html_template(data), encoding="utf-8")
|
||||
scored = [c for c in data["commodities"] if c["total"] is not None]
|
||||
print(f"Wrote {OUTPUT_FILE}")
|
||||
print(f"Scored commodities: {len(scored)} / {len(data['commodities'])}")
|
||||
if scored:
|
||||
print("Top 5:", ", ".join(f"{c['name']} {c['total']}" for c in scored[:5]))
|
||||
print("Bottom 5:", ", ".join(f"{c['name']} {c['total']}" for c in scored[-5:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PORT = 8765
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/refresh":
|
||||
self.send_error(404)
|
||||
return
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(BASE_DIR / "generate_fundamental_dashboard.py")],
|
||||
cwd=str(BASE_DIR),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
body = (proc.stdout or "") + (proc.stderr or "")
|
||||
self.send_response(200 if proc.returncode == 0 else 500)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode("utf-8", errors="replace"))
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
super().end_headers()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"http://127.0.0.1:{PORT}/基本面评分面板.html")
|
||||
server.serve_forever()
|
||||
Binary file not shown.
Binary file not shown.
+529
File diff suppressed because one or more lines are too long
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: A-期货日级数据
|
||||
description: 通过 mysql_dll2.dll 连接 MySQL 数据库并执行 SQL 查询。当用户需要查询 MySQL 数据库、执行 SQL 语句、获取数据库中的数据时触发。 触发关键词包括:查询数据库、查合约、查日线、查K线、查结算价、查持仓量、查成交量、查保证金、 查品种数据、读取数据库、SQL查询、查某品种某合约、查期货数据、从数据库取数据、db查询、查纯碱、查螺纹等。 注意:此 Skill 依赖 mysql_dll2.dll 文件,仅限 Windows 平台使用。数据库编号固定为 1。
|
||||
disable: true
|
||||
allowed-tools:
|
||||
---
|
||||
|
||||
.dll 连接 MySQL 数据库并执行 SQL 查询。当用户需要查询 MySQL 数据库、执行 SQL 语句、获取数据库中的数据时触发。
|
||||
触发关键词包括:查询数据库、查合约、查日线、查K线、查结算价、查持仓量、查成交量、查保证金、
|
||||
查品种数据、读取数据库、SQL查询、查某品种某合约、查期货数据、从数据库取数据、db查询、查纯碱、查螺纹等。
|
||||
注意:此 Skill 依赖 mysql_dll2.dll 文件,仅限 Windows 平台使用。数据库编号固定为 1。
|
||||
allowed-tools:
|
||||
disable: true
|
||||
---
|
||||
|
||||
# A-期货日级数据
|
||||
|
||||
## 用途
|
||||
|
||||
把自然语言翻译成 SQL 执行,返回原始数据。不生成汇总、不分析、不联想。
|
||||
|
||||
---
|
||||
|
||||
## 数据库
|
||||
|
||||
- **db_index:固定为 1**,无需询问用户
|
||||
|
||||
---
|
||||
|
||||
## 调用方式
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, r"<skill_scripts_dir>")
|
||||
from mysql_client import MysqlDLLClient
|
||||
|
||||
db = MysqlDLLClient()
|
||||
result = db.query(1, "SELECT * FROM `contract_day` WHERE p_code = ?", ["sa"])
|
||||
print(result)
|
||||
db.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段说明(references/field_mapping.md)
|
||||
|
||||
| 英文字段名 | 中文含义 | 字段类型 |
|
||||
|-----------|---------|---------|
|
||||
| contract | 合约 | varchar(10) |
|
||||
| open | 开盘价 | float |
|
||||
| high | 高(最高价) | float |
|
||||
| low | 低(最低价) | float |
|
||||
| close | 收(收盘价) | float |
|
||||
| volume | 成交(成交量) | float |
|
||||
| oi | 持仓(持仓量) | float |
|
||||
| p_code | 品种 | varchar(4) |
|
||||
| jys | 交易所 | varchar(6) |
|
||||
| times | 时间 | datetime |
|
||||
|
||||
### 品种 vs 合约
|
||||
|
||||
| 概念 | 字段 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| 品种 | `p_code` | 品种代码,拼音小写 | `sa`(纯碱)、`rb`(螺纹钢) |
|
||||
| 合约 | `contract` | 品种 + 交割月份 | `sa605`(纯碱2026年5月交割) |
|
||||
|
||||
> 郑商所(CZCE)合约月份为3位,如 `SR501`、`MA509`。
|
||||
|
||||
---
|
||||
|
||||
## 查询示例
|
||||
|
||||
**查所有品种某时间段数据(直接一条SQL搞定,不需要先查品种)**
|
||||
|
||||
```sql
|
||||
SELECT * FROM contract_day WHERE times >= '2026-01-01' AND times <= '2026-12-31'
|
||||
```
|
||||
|
||||
**查纯碱26年所有合约数据**
|
||||
|
||||
```
|
||||
p_code = 'SA'
|
||||
时间范围: times >= '2026-01-01' AND times <= '2026-12-31'
|
||||
```
|
||||
|
||||
**查纯碱605合约数据**
|
||||
|
||||
```
|
||||
contract = 'SA605'
|
||||
```
|
||||
|
||||
**查螺纹钢26年所有数据**
|
||||
|
||||
```
|
||||
p_code = 'RB'
|
||||
时间范围: times >= '2026-01-01' AND times <= '2026-12-31'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. **直接执行,不询问确认。**
|
||||
2. **只返回数据,不生成汇总、不分析。**
|
||||
3. **db_index 固定为 1。**
|
||||
4. **参数化查询**,所有条件用 `?` 占位符传参。
|
||||
5. **查所有品种时直接一条 SQL**,不需要先 `SELECT DISTINCT p_code` 再逐条查。
|
||||
|
||||
---
|
||||
|
||||
## 依赖文件
|
||||
|
||||
```
|
||||
scripts/
|
||||
└── mysql_client.py
|
||||
lib/
|
||||
└── mysql_dll/
|
||||
├── mysql_dll2.dll
|
||||
├── jsoncpp.dll
|
||||
├── libcrypto-1_1-x64.dll
|
||||
├── libmysql.dll
|
||||
├── libssl-1_1-x64.dll
|
||||
└── mysqlcppconn-9-vs14.dll
|
||||
```
|
||||
|
||||
DLL 由 `mysql_client.py` 自动加载,无需手动配置。
|
||||
|
||||
---
|
||||
|
||||
## 品种代码对照(references/variety_mapping.md)
|
||||
|
||||
详见 `references/variety_mapping.md`,包含所有交易所品种代码。
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
# 数据库字段中英文对照表
|
||||
|
||||
## 品种 vs 合约
|
||||
|
||||
| 概念 | 字段 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **品种** | `p_code` | 品种代码,拼音小写,代表品种分类 | `rb`(螺纹钢)、`cu`(铜) |
|
||||
| **合约** | `contract` | 具体合约代码 = 品种 + 交割月份 | `rb2506`(螺纹钢2025年6月交割) |
|
||||
|
||||
> 郑商所(CZCE)合约月份为3位,如 `SR501`、`MA509`。
|
||||
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 英文字段名 | 中文注释 | 字段类型 |
|
||||
|-----------|---------|---------|
|
||||
| contract | 合约 | varchar(10) |
|
||||
| open | 开盘价 | float |
|
||||
| high | 高(最高价) | float |
|
||||
| low | 低(最低价) | float |
|
||||
| close | 收(收盘价) | float |
|
||||
| volume | 成交(成交量) | float |
|
||||
| oi | 持仓(持仓量) | float |
|
||||
| p_code | 品种 | varchar(4) |
|
||||
| jys | 交易所 | varchar(6) |
|
||||
| times | 时间 | datetime |
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 期货品种中英文对照表
|
||||
|
||||
## 中金所(CFFEX)
|
||||
|
||||
| p_code(查询用) | 中文名称 | 合约位数 | 备注 |
|
||||
|-----------------|---------|---------|------|
|
||||
| if / IF | 沪深300股指期货 | 4位 | 如 IF2506 |
|
||||
| ih / IH | 上证50股指期货 | 4位 | 如 IH2506 |
|
||||
| im / IM | 中证1000股指期货 | 4位 | 如 IM2506 |
|
||||
| ic / IC | 中证500股指期货 | 4位 | 如 IC2506 |
|
||||
| tf / TF | 5年国债期货 | 4位 | 如 TF2506 |
|
||||
| t / T | 10年国债期货 | 4位 | 如 T2506 |
|
||||
| ts / TS | 2年国债期货 | 4位 | 如 TS2506 |
|
||||
| tl / TL | 30年国债期货 | 4位 | 如 TL2506 |
|
||||
|
||||
---
|
||||
|
||||
## 上海期货交易所(SHFE)
|
||||
|
||||
| p_code | 中文名称 | 合约位数 | 示例 |
|
||||
|--------|---------|---------|------|
|
||||
| cu | 铜 | 4位 | cu2506 |
|
||||
| al | 铝 | 4位 | al2506 |
|
||||
| zn | 锌 | 4位 | zn2506 |
|
||||
| pb | 铅 | 4位 | pb2506 |
|
||||
| ni | 镍 | 4位 | ni2506 |
|
||||
| sn | 锡 | 4位 | sn2506 |
|
||||
| au | 黄金 | 4位 | au2506 |
|
||||
| ag | 白银 | 4位 | ag2506 |
|
||||
| rb | 螺纹钢 | 4位 | rb2506 |
|
||||
| wr | 线材 | 4位 | wr2506 |
|
||||
| hc | 热轧卷板 | 4位 | hc2506 |
|
||||
| ss | 不锈钢 | 4位 | ss2506 |
|
||||
| fu | 燃料油 | 4位 | fu2506 |
|
||||
| bu | 沥青 | 4位 | bu2506 |
|
||||
| ru | 天然橡胶 | 4位 | ru2506 |
|
||||
| sp | 纸浆 | 4位 | sp2506 |
|
||||
| br | 丁二烯橡胶 | 4位 | br2506 |
|
||||
|
||||
---
|
||||
|
||||
## 大连商品交易所(DCE)
|
||||
|
||||
| p_code | 中文名称 | 合约位数 | 示例 |
|
||||
|--------|---------|---------|------|
|
||||
| a | 豆一 | 4位 | a2506 |
|
||||
| b | 豆二 | 4位 | b2506 |
|
||||
| m | 豆粕 | 4位 | m2506 |
|
||||
| y | 豆油 | 4位 | y2506 |
|
||||
| p | 棕榈油 | 4位 | p2506 |
|
||||
| c | 玉米 | 4位 | c2506 |
|
||||
| cs | 玉米淀粉 | 4位 | cs2506 |
|
||||
| jd | 鸡蛋 | 4位 | jd2506 |
|
||||
| l | 聚乙烯 | 4位 | l2506 |
|
||||
| v | 聚氯乙烯 | 4位 | v2506 |
|
||||
| pp | 聚丙烯 | 4位 | pp2506 |
|
||||
| j | 焦炭 | 4位 | j2506 |
|
||||
| jm | 焦煤 | 4位 | jm2506 |
|
||||
| i | 铁矿石 | 4位 | i2506 |
|
||||
| eg | 乙二醇 | 4位 | eg2506 |
|
||||
| rr | 粳米 | 4位 | rr2506 |
|
||||
| eb | 苯乙烯 | 4位 | eb2506 |
|
||||
| pg | 液化石油气 | 4位 | pg2506 |
|
||||
| lh | 生猪 | 4位 | lh2506 |
|
||||
|
||||
---
|
||||
|
||||
## 郑州商品交易所(CZCE)
|
||||
|
||||
> ⚠️ **郑商所合约特殊规则:合约月份只有 3 位数字**
|
||||
> 例如:SR501(白糖2501年1月),MA509(甲醇2509年9月)
|
||||
> 年份只取个位数,与其他交易所4位不同!
|
||||
|
||||
| p_code | 中文名称 | 合约格式 | 示例 |
|
||||
|--------|---------|---------|------|
|
||||
| SR | 白糖 | 品种+3位 | SR501 |
|
||||
| CF | 棉花 | 品种+3位 | CF501 |
|
||||
| MA | 甲醇 | 品种+3位 | MA509 |
|
||||
| TA | PTA | 品种+3位 | TA509 |
|
||||
| OI | 菜籽油 | 品种+3位 | OI509 |
|
||||
| RM | 菜籽粕 | 品种+3位 | RM509 |
|
||||
| RS | 菜籽 | 品种+3位 | RS509 |
|
||||
| WH | 强麦 | 品种+3位 | WH509 |
|
||||
| PM | 普麦 | 品种+3位 | PM509 |
|
||||
| RI | 早籼稻 | 品种+3位 | RI509 |
|
||||
| JR | 粳稻 | 品种+3位 | JR509 |
|
||||
| LR | 晚籼稻 | 品种+3位 | LR509 |
|
||||
| AP | 苹果 | 品种+3位 | AP509 |
|
||||
| CJ | 红枣 | 品种+3位 | CJ509 |
|
||||
| SF | 硅铁 | 品种+3位 | SF509 |
|
||||
| SM | 锰硅 | 品种+3位 | SM509 |
|
||||
| ZC | 动力煤 | 品种+3位 | ZC509 |
|
||||
| FG | 玻璃 | 品种+3位 | FG509 |
|
||||
| SA | 纯碱 | 品种+3位 | SA509 |
|
||||
| UR | 尿素 | 品种+3位 | UR509 |
|
||||
| PF | 短纤 | 品种+3位 | PF509 |
|
||||
| PX | 对二甲苯 | 品种+3位 | PX509 |
|
||||
| PR | 丙烯 | 品种+3位 | PR509 |
|
||||
| SH | 烧碱 | 品种+3位 | SH509 |
|
||||
|
||||
---
|
||||
|
||||
## 上海国际能源交易中心(INE)
|
||||
|
||||
| p_code | 中文名称 | 合约位数 | 示例 |
|
||||
|--------|---------|---------|------|
|
||||
| sc | 原油 | 4位 | sc2506 |
|
||||
| lu | 低硫燃料油 | 4位 | lu2506 |
|
||||
| nr | 20号胶 | 4位 | nr2506 |
|
||||
| bc | 国际铜 | 4位 | bc2506 |
|
||||
|
||||
---
|
||||
|
||||
## 广州期货交易所(GFEX)
|
||||
|
||||
| p_code | 中文名称 | 合约位数 | 示例 |
|
||||
|--------|---------|---------|------|
|
||||
| si | 工业硅 | 4位 | si2506 |
|
||||
| lc | 碳酸锂 | 4位 | lc2506 |
|
||||
@@ -0,0 +1,172 @@
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
|
||||
class MysqlDLLClient:
|
||||
"""
|
||||
MySQL DLL 调用客户端
|
||||
用于调用 mysql_dll2.dll 执行 SQL
|
||||
|
||||
Example
|
||||
-------
|
||||
db = MysqlDLLClient()
|
||||
|
||||
data = db.query(
|
||||
7,
|
||||
"select * from min15_a_jq where times>=? and times<=?",
|
||||
["2025-12-24 00:00:00", "2025-12-26 00:00:00"]
|
||||
)
|
||||
|
||||
print(data)
|
||||
"""
|
||||
|
||||
def __init__(self, dll_path=None):
|
||||
"""
|
||||
初始化 DLL
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dll_path : str | Path
|
||||
dll路径,默认当前目录 mysql_dll2.dll
|
||||
"""
|
||||
|
||||
if dll_path is None:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
dll_path = root / "lib/mysql_dll/mysql_dll2.dll"
|
||||
|
||||
self.dll = ctypes.WinDLL(str(dll_path))
|
||||
|
||||
self._init_functions()
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
ret = self.dll.InitDBConfig()
|
||||
if ret != 1:
|
||||
raise RuntimeError("服务器授权失败")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 初始化 DLL 函数声明
|
||||
# ---------------------------------------------------------
|
||||
def _init_functions(self):
|
||||
|
||||
# 初始化数据库配置
|
||||
self.dll.InitDBConfig.argtypes = []
|
||||
self.dll.InitDBConfig.restype = ctypes.c_int
|
||||
|
||||
# 关闭 DLL
|
||||
self.dll.ShutdownDLL.argtypes = []
|
||||
self.dll.ShutdownDLL.restype = None
|
||||
|
||||
# QuerySQL_Auto
|
||||
self.dll.QuerySQL_Auto.argtypes = [
|
||||
ctypes.c_int, # db_index
|
||||
ctypes.c_char_p, # sql
|
||||
ctypes.c_char_p, # params
|
||||
ctypes.POINTER(ctypes.c_void_p), # out_buf
|
||||
ctypes.POINTER(ctypes.c_int) # out_size
|
||||
]
|
||||
|
||||
self.dll.QuerySQL_Auto.restype = ctypes.c_int
|
||||
|
||||
# FreeBuffer
|
||||
self.dll.FreeBuffer.argtypes = [ctypes.c_void_p]
|
||||
self.dll.FreeBuffer.restype = None
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 参数转换
|
||||
# ---------------------------------------------------------
|
||||
def _build_params(self, params):
|
||||
"""
|
||||
参数列表转 DLL 参数格式
|
||||
|
||||
["2025-01-01","2025-01-02"]
|
||||
|
||||
->
|
||||
|
||||
b"s:2025-01-01|s:2025-01-02"
|
||||
"""
|
||||
|
||||
if not params:
|
||||
return b""
|
||||
|
||||
arr = []
|
||||
|
||||
for p in params:
|
||||
if isinstance(p, str):
|
||||
arr.append(f"s:{p}")
|
||||
elif isinstance(p, int):
|
||||
arr.append(f"i:{p}")
|
||||
elif isinstance(p, float):
|
||||
arr.append(f"f:{p}")
|
||||
else:
|
||||
raise TypeError(f"不支持的参数类型: {type(p)}")
|
||||
|
||||
return "|".join(arr).encode()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 查询
|
||||
# ---------------------------------------------------------
|
||||
def query(self, db_index, sql, params=None):
|
||||
"""
|
||||
执行 SQL 查询
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db_index : int
|
||||
数据库编号
|
||||
|
||||
sql : str
|
||||
SQL语句
|
||||
|
||||
params : list
|
||||
SQL参数
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
查询结果(JSON 或文本)
|
||||
"""
|
||||
|
||||
if isinstance(sql, str):
|
||||
sql = sql.encode()
|
||||
|
||||
params_bytes = self._build_params(params)
|
||||
|
||||
out_buf = ctypes.c_void_p()
|
||||
out_size = ctypes.c_int()
|
||||
|
||||
ret = self.dll.QuerySQL_Auto(
|
||||
db_index,
|
||||
sql,
|
||||
params_bytes,
|
||||
ctypes.byref(out_buf),
|
||||
ctypes.byref(out_size)
|
||||
)
|
||||
|
||||
if ret != 0:
|
||||
raise RuntimeError("SQL执行失败")
|
||||
|
||||
try:
|
||||
data = ctypes.string_at(out_buf.value, out_size.value)
|
||||
return json.loads(data.decode())
|
||||
finally:
|
||||
self.dll.FreeBuffer(out_buf)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 关闭
|
||||
# ---------------------------------------------------------
|
||||
def close(self):
|
||||
"""关闭 DLL"""
|
||||
if self.dll:
|
||||
self.dll.ShutdownDLL()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# with 支持
|
||||
# ---------------------------------------------------------
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user