feat: 月差模块独立+阴影带分位数法+季节性历史折线
- 新增 spread_from_db.py: 从MySQL动态计算月差,含标准月差对/主次月差对/历史同期序列 - 阴影带计算改为分位数+K缩放法: mid=Q50, low=Q(trim), high=Q(1-trim), widthScale=K/3 上下沿各自取自数据分布,天然跟随季节性方向 - 月差图支持历史同期折线: 同一月份组合的多条历史合约对序列独立绘制 - 月差图滚动平均平滑(smooth_window=5): 消除价差固有日度抖动,不影响评分 - OI筛选仅用于确定面板展示的月差对,历史序列不过滤持仓 - 前端支持K值/trim实时调节,scoreFromCurrentBand动态计算得分
This commit is contained in:
@@ -12,22 +12,30 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
try:
|
||||||
|
from spread_from_db import read_spread_from_db_batch, build_spread_seasonal_data, NAME_TO_PCODE as SPREAD_NAME_TO_PCODE
|
||||||
|
_HAS_SPREAD_DB = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_SPREAD_DB = False
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
CONFIRM_FILE = BASE_DIR / "基本面指标确认.xlsx"
|
CONFIRM_FILE = BASE_DIR / "基本面指标确认.xlsx"
|
||||||
WIND_FILE = BASE_DIR / "wind数据汇总.xlsx"
|
WIND_FILE = BASE_DIR / "wind数据汇总.xlsx"
|
||||||
STEEL_FILE = BASE_DIR / "钢联数据汇总.xlsx"
|
STEEL_FILE = BASE_DIR / "钢联数据汇总.xlsx"
|
||||||
|
SPREAD_FILE = BASE_DIR / "月差拼接数据.xlsx"
|
||||||
OUTPUT_FILE = BASE_DIR / "基本面评分面板.html"
|
OUTPUT_FILE = BASE_DIR / "基本面评分面板.html"
|
||||||
|
|
||||||
DIMENSIONS = ["利润", "产量", "库存", "需求"]
|
DIMENSIONS = ["利润", "产量", "库存", "需求", "月差"]
|
||||||
INVERSE_DIMS = {"库存"}
|
INVERSE_DIMS = {"库存"}
|
||||||
DIM_COLORS = {
|
DIM_COLORS = {
|
||||||
"利润": "#2563eb",
|
"利润": "#2563eb",
|
||||||
"产量": "#dc2626",
|
"产量": "#dc2626",
|
||||||
"库存": "#1d4ed8",
|
"库存": "#1d4ed8",
|
||||||
"需求": "#0f9f6e",
|
"需求": "#0f9f6e",
|
||||||
"月差": "#64748b",
|
"月差": "#9333ea",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_BAND_WINDOW = 15
|
DEFAULT_BAND_WINDOW = 15
|
||||||
DEFAULT_BAND_K = 3.0
|
DEFAULT_BAND_K = 3.0
|
||||||
DEFAULT_BAND_TRIM = 0.10
|
DEFAULT_BAND_TRIM = 0.10
|
||||||
@@ -178,15 +186,15 @@ def seasonal_band_sample(history: pd.Series, day: int, window: int = DEFAULT_BAN
|
|||||||
|
|
||||||
def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool = False) -> dict[str, Any] | None:
|
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, used_window = seasonal_band_sample(history, day)
|
||||||
sample = trimmed_values(sample)
|
sample = sample.sort_values()
|
||||||
if len(sample) < 4:
|
if len(sample) < 4:
|
||||||
return None
|
return None
|
||||||
mean = float(sample.mean())
|
mid = float(sample.quantile(0.5))
|
||||||
std = float(sample.std(ddof=0))
|
q_low = float(sample.quantile(DEFAULT_BAND_TRIM))
|
||||||
if not math.isfinite(std):
|
q_high = float(sample.quantile(1 - DEFAULT_BAND_TRIM))
|
||||||
std = 0.0
|
width_scale = DEFAULT_BAND_K / 3
|
||||||
low = mean - DEFAULT_BAND_K * std
|
low = mid - (mid - q_low) * width_scale
|
||||||
high = mean + DEFAULT_BAND_K * std
|
high = mid + (q_high - mid) * width_scale
|
||||||
if not math.isfinite(low) or not math.isfinite(high):
|
if not math.isfinite(low) or not math.isfinite(high):
|
||||||
return None
|
return None
|
||||||
if high == low:
|
if high == low:
|
||||||
@@ -202,7 +210,7 @@ def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool
|
|||||||
"score": max(0.0, min(100.0, score)),
|
"score": max(0.0, min(100.0, score)),
|
||||||
"band_low": low,
|
"band_low": low,
|
||||||
"band_high": high,
|
"band_high": high,
|
||||||
"band_mean": mean,
|
"band_mean": mid,
|
||||||
"band_window": used_window,
|
"band_window": used_window,
|
||||||
"band_k": DEFAULT_BAND_K,
|
"band_k": DEFAULT_BAND_K,
|
||||||
"band_trim": DEFAULT_BAND_TRIM,
|
"band_trim": DEFAULT_BAND_TRIM,
|
||||||
@@ -292,15 +300,18 @@ def build_score_history(dim_series: dict[str, pd.Series]) -> list[dict[str, Any]
|
|||||||
scores[dim] = score
|
scores[dim] = score
|
||||||
if not scores:
|
if not scores:
|
||||||
continue
|
continue
|
||||||
dims_with_demand = [d for d in DIMENSIONS if d in scores]
|
dims_with_spread = [d for d in DIMENSIONS if d in scores]
|
||||||
dims_without_demand = [d for d in DIMENSIONS if d != "需求" and 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
|
dims_no_demand_no_spread = [d for d in DIMENSIONS if d not in ("需求", "月差") and d in scores]
|
||||||
|
total = float(np.mean([scores[d] for d in dims_with_spread])) if dims_with_spread else None
|
||||||
total_no_demand = float(np.mean([scores[d] for d in dims_without_demand])) if dims_without_demand else None
|
total_no_demand = float(np.mean([scores[d] for d in dims_without_demand])) if dims_without_demand else None
|
||||||
|
total_no_demand_no_spread = float(np.mean([scores[d] for d in dims_no_demand_no_spread])) if dims_no_demand_no_spread else None
|
||||||
history.append(
|
history.append(
|
||||||
{
|
{
|
||||||
"date": date.strftime("%Y-%m-%d"),
|
"date": date.strftime("%Y-%m-%d"),
|
||||||
"total": round(total, 1) if total is not None else None,
|
"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,
|
"totalNoDemand": round(total_no_demand, 1) if total_no_demand is not None else None,
|
||||||
|
"totalNoDemandNoSpread": round(total_no_demand_no_spread, 1) if total_no_demand_no_spread is not None else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return history
|
return history
|
||||||
@@ -312,6 +323,12 @@ def make_records() -> dict[str, Any]:
|
|||||||
metrics.extend(read_metrics(STEEL_FILE, "钢联", 1, 2, 5, 10, "钢联"))
|
metrics.extend(read_metrics(STEEL_FILE, "钢联", 1, 2, 5, 10, "钢联"))
|
||||||
metric_index, metric_names = build_metric_index(metrics)
|
metric_index, metric_names = build_metric_index(metrics)
|
||||||
confirm = pd.read_excel(CONFIRM_FILE).fillna("")
|
confirm = pd.read_excel(CONFIRM_FILE).fillna("")
|
||||||
|
# 从 MySQL 批量加载所有品种月差数据
|
||||||
|
if _HAS_SPREAD_DB:
|
||||||
|
all_variety_names = [str(n).strip() for n in confirm["品种"] if str(n).strip()]
|
||||||
|
spread_db = read_spread_from_db_batch(all_variety_names)
|
||||||
|
else:
|
||||||
|
spread_db = {}
|
||||||
commodities: list[dict[str, Any]] = []
|
commodities: list[dict[str, Any]] = []
|
||||||
for idx, row in confirm.iterrows():
|
for idx, row in confirm.iterrows():
|
||||||
name = str(row["品种"]).strip()
|
name = str(row["品种"]).strip()
|
||||||
@@ -320,7 +337,7 @@ def make_records() -> dict[str, Any]:
|
|||||||
dims: dict[str, Any] = {}
|
dims: dict[str, Any] = {}
|
||||||
dim_series: dict[str, pd.Series] = {}
|
dim_series: dict[str, pd.Series] = {}
|
||||||
valid_scores: list[float] = []
|
valid_scores: list[float] = []
|
||||||
for dim in DIMENSIONS:
|
for dim in ["利润", "产量", "库存", "需求"]:
|
||||||
series, missing, used_metrics = eval_expression(row.get(dim, ""), metric_index, metric_names)
|
series, missing, used_metrics = eval_expression(row.get(dim, ""), metric_index, metric_names)
|
||||||
if series is not None:
|
if series is not None:
|
||||||
dim_series[dim] = series
|
dim_series[dim] = series
|
||||||
@@ -351,6 +368,54 @@ def make_records() -> dict[str, Any]:
|
|||||||
"missing": missing,
|
"missing": missing,
|
||||||
"metrics": [{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq} for m in used_metrics],
|
"metrics": [{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq} for m in used_metrics],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 月差维度:从 MySQL contract_day 动态计算
|
||||||
|
dim = "月差"
|
||||||
|
spread_info = spread_db.get(name, {})
|
||||||
|
spread_series_db = spread_info.get("series")
|
||||||
|
if spread_series_db is not None and not spread_series_db.empty:
|
||||||
|
dim_series[dim] = spread_series_db
|
||||||
|
# 转换为 seasonal_percentile 兼容格式
|
||||||
|
stat = build_spread_seasonal_data(spread_series_db)
|
||||||
|
else:
|
||||||
|
stat = None
|
||||||
|
|
||||||
|
if stat:
|
||||||
|
latest_dt = pd.Timestamp(stat["latest_date"])
|
||||||
|
history = spread_series_db[spread_series_db.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"] if stat.get("raw_percentile") is not None else 50.0
|
||||||
|
score = 100.0 - raw if dim in INVERSE_DIMS else raw
|
||||||
|
stat["score"] = round(score, 1)
|
||||||
|
stat["direction"] = "高于正常区间更优(近月升水=强势)"
|
||||||
|
stat["expr"] = "近月(top3 OI) - 远月(最远>1万手)"
|
||||||
|
# 月差对详情
|
||||||
|
pair_scores = spread_info.get("pair_scores", [])
|
||||||
|
stat["pair_scores"] = pair_scores
|
||||||
|
pair_desc = " / ".join(
|
||||||
|
f"{ps['pair_label']}:{ps.get('score', '--')}" for ps in pair_scores
|
||||||
|
) if pair_scores else ""
|
||||||
|
stat["metrics"] = [{
|
||||||
|
"name": f"月差对({len(pair_scores)}对)",
|
||||||
|
"source": "MySQL contract_day",
|
||||||
|
"unit": "",
|
||||||
|
"freq": "日",
|
||||||
|
"detail": pair_desc,
|
||||||
|
}]
|
||||||
|
valid_scores.append(float(score))
|
||||||
|
dims[dim] = stat or {
|
||||||
|
"score": None,
|
||||||
|
"raw_percentile": None,
|
||||||
|
"direction": "缺数据" if not _HAS_SPREAD_DB else "月差数据未找到",
|
||||||
|
"expr": "近月(top3 OI) - 远月(最远>1万手)",
|
||||||
|
"missing": [] if _HAS_SPREAD_DB else ["spread_from_db 模块不可用"],
|
||||||
|
"metrics": [],
|
||||||
|
}
|
||||||
|
|
||||||
total = round(float(np.mean(valid_scores)), 1) if valid_scores else None
|
total = round(float(np.mean(valid_scores)), 1) if valid_scores else None
|
||||||
boards = commodity_boards(name)
|
boards = commodity_boards(name)
|
||||||
commodities.append(
|
commodities.append(
|
||||||
@@ -381,8 +446,9 @@ def make_records() -> dict[str, Any]:
|
|||||||
"wind": WIND_FILE.name,
|
"wind": WIND_FILE.name,
|
||||||
"steel": STEEL_FILE.name,
|
"steel": STEEL_FILE.name,
|
||||||
"confirm": CONFIRM_FILE.name,
|
"confirm": CONFIRM_FILE.name,
|
||||||
|
"spread_source": "MySQL contract_day (spread_from_db.py)",
|
||||||
},
|
},
|
||||||
"dimensions": DIMENSIONS + ["月差"],
|
"dimensions": DIMENSIONS,
|
||||||
"boards": sorted(set(board for c in commodities for board in c.get("boards", [c["board"]]))),
|
"boards": sorted(set(board for c in commodities for board in c.get("boards", [c["board"]]))),
|
||||||
"completion": {
|
"completion": {
|
||||||
"count": completion_count,
|
"count": completion_count,
|
||||||
@@ -424,7 +490,7 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
|
|||||||
.kpi-number {{ font-size:42px; line-height:1; font-weight:900; color:#111827; }}
|
.kpi-number {{ font-size:42px; line-height:1; font-weight:900; color:#111827; }}
|
||||||
.hint {{ color:var(--muted); font-size:12px; line-height:1.55; }}
|
.hint {{ color:var(--muted); font-size:12px; line-height:1.55; }}
|
||||||
.chips {{ display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; }}
|
.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 {{ border:1px solid #4c84ff; color:#1d4ed8; border-radius:4px; padding:3px 7px; min-width:0; display:flex; justify-content:space-between; align-items:center; font-size:12px; font-weight:600; background:#f8fbff; white-space:nowrap; }}
|
||||||
.chip.red {{ border-color:#ef4444; color:#dc2626; background:#fffafa; }}
|
.chip.red {{ border-color:#ef4444; color:#dc2626; background:#fffafa; }}
|
||||||
.chip.good {{ border-color:#22c55e; color:#047857; background:#f6fffa; }}
|
.chip.good {{ border-color:#22c55e; color:#047857; background:#f6fffa; }}
|
||||||
.note {{ padding:4px 14px 10px; color:#64748b; font-size:12px; }}
|
.note {{ padding:4px 14px 10px; color:#64748b; font-size:12px; }}
|
||||||
@@ -449,7 +515,7 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
|
|||||||
.commodity:first-child {{ border-top:0; }}
|
.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; }}
|
.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; }}
|
.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; }}
|
.scoreline {{ display:grid; grid-template-columns:160px repeat(5, minmax(120px, 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 {{ display:flex; align-items:center; gap:8px; font-weight:900; color:var(--blue); white-space:nowrap; }}
|
||||||
.total .num {{ font-size:28px; }}
|
.total .num {{ font-size:28px; }}
|
||||||
.dimbox {{ background:white; border-left:4px solid var(--blue); padding:5px 8px; min-height:42px; }}
|
.dimbox {{ background:white; border-left:4px solid var(--blue); padding:5px 8px; min-height:42px; }}
|
||||||
@@ -458,9 +524,11 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
|
|||||||
.dimscore {{ font-size:13px; font-weight:900; color:#1d4ed8; }}
|
.dimscore {{ font-size:13px; font-weight:900; color:#1d4ed8; }}
|
||||||
.dimhint {{ font-size:12px; color:#667085; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }}
|
.dimhint {{ font-size:12px; color:#667085; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }}
|
||||||
.detail {{ padding:0 10px 8px; color:#667085; font-size:12px; }}
|
.detail {{ padding:0 10px 8px; color:#667085; font-size:12px; }}
|
||||||
.charts {{ display:grid; grid-template-columns:repeat(4, minmax(260px,1fr)); }}
|
.charts {{ display:grid; grid-template-columns:repeat(4, minmax(220px,1fr)); }}
|
||||||
|
.spread-charts {{ display:grid; grid-template-columns:repeat(3, minmax(260px,1fr)); border-top:2px solid #9333ea; }}
|
||||||
.chartcell {{ min-width:0; border-top:1px solid var(--line); border-right:1px solid var(--line); padding:8px 10px 10px; }}
|
.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; }}
|
.charts .chartcell:nth-child(4n) {{ border-right:0; }}
|
||||||
|
.spread-charts .chartcell:nth-child(3n) {{ border-right:0; }}
|
||||||
.chart-title {{ text-align:center; font-size:18px; color:#475467; margin:2px 0 4px; }}
|
.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; }}
|
.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; }}
|
svg {{ width:100%; height:230px; display:block; }}
|
||||||
@@ -473,6 +541,7 @@ svg {{ width:100%; height:230px; display:block; }}
|
|||||||
.summary {{ grid-template-columns:1fr; }}
|
.summary {{ grid-template-columns:1fr; }}
|
||||||
.scoreline {{ grid-template-columns:1fr 1fr 1fr; }}
|
.scoreline {{ grid-template-columns:1fr 1fr 1fr; }}
|
||||||
.charts {{ grid-template-columns:1fr 1fr; }}
|
.charts {{ grid-template-columns:1fr 1fr; }}
|
||||||
|
.spread-charts {{ grid-template-columns:1fr 1fr; }}
|
||||||
input {{ width:220px; }}
|
input {{ width:220px; }}
|
||||||
}}
|
}}
|
||||||
@media (max-width: 760px) {{
|
@media (max-width: 760px) {{
|
||||||
@@ -490,10 +559,11 @@ svg {{ width:100%; height:230px; display:block; }}
|
|||||||
<label class="ctrl">维度 <select id="dim"><option>全部</option></select></label>
|
<label class="ctrl">维度 <select id="dim"><option>全部</option></select></label>
|
||||||
<label class="ctrl">板块 <select id="board"><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="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">带宽K <input class="mini" id="bandK" type="number" min="0.5" max="12" 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 class="mini" id="bandTrim" type="number" min="0" max="40" step="5" value="10"></label>
|
||||||
<label class="ctrl">图始 <select id="chartStartYear"></select></label>
|
<label class="ctrl">图始 <select id="chartStartYear"></select></label>
|
||||||
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
|
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
|
||||||
|
<label class="ctrl"><input id="includeSpread" type="checkbox" checked>月差计分</label>
|
||||||
<button type="button" class="tabbtn active" id="dashboardTab">面板</button>
|
<button type="button" class="tabbtn active" id="dashboardTab">面板</button>
|
||||||
<button type="button" class="tabbtn" id="historyTab">评分走势</button>
|
<button type="button" class="tabbtn" id="historyTab">评分走势</button>
|
||||||
<button type="button" id="refreshData">刷新数据</button>
|
<button type="button" id="refreshData">刷新数据</button>
|
||||||
@@ -504,7 +574,7 @@ svg {{ width:100%; height:230px; display:block; }}
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="kpi-title">基本面总分</div>
|
<div class="kpi-title">基本面总分</div>
|
||||||
<div class="kpi-number" id="avgScore">--</div>
|
<div class="kpi-number" id="avgScore">--</div>
|
||||||
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种;月差当前暂无映射,未计入总分。</div>
|
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="kpi-title">基本面较强</div>
|
<div class="kpi-title">基本面较强</div>
|
||||||
@@ -515,7 +585,7 @@ svg {{ width:100%; height:230px; display:block; }}
|
|||||||
<div class="chips" id="weak"></div>
|
<div class="chips" id="weak"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="note">维度得分按当前阴影带范围计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。顶部阴影带参数同步影响维度分数和总分。</div>
|
<div class="note">维度得分按当前阴影带范围计算(均值±K×标准差):正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。调整顶部「带宽K」可实时改变阴影带宽度和得分。</div>
|
||||||
<main class="rows" id="rows"></main>
|
<main class="rows" id="rows"></main>
|
||||||
<section class="history-view" id="historyView">
|
<section class="history-view" id="historyView">
|
||||||
<div class="history-tools">
|
<div class="history-tools">
|
||||||
@@ -586,11 +656,11 @@ function majorityHistoryEndDate() {{
|
|||||||
}}
|
}}
|
||||||
historyEnd.value = majorityHistoryEndDate();
|
historyEnd.value = majorityHistoryEndDate();
|
||||||
let activeView = "dashboard";
|
let activeView = "dashboard";
|
||||||
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; chartStartYear.value="2021"; includeDemand.checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
|
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; chartStartYear.value="2021"; includeDemand.checked=true; document.getElementById("includeSpread").checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
|
||||||
dashboardTab.onclick = () => {{ activeView="dashboard"; dashboardTab.classList.add("active"); historyTab.classList.remove("active"); 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(); }};
|
historyTab.onclick = () => {{ activeView="history"; historyTab.classList.add("active"); dashboardTab.classList.remove("active"); render(); }};
|
||||||
refreshData.onclick = refreshFromLocalProgram;
|
refreshData.onclick = refreshFromLocalProgram;
|
||||||
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = chartStartYear.onchange = includeDemand.onchange = historyStart.onchange = historyEnd.onchange = render;
|
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = chartStartYear.onchange = includeDemand.onchange = document.getElementById("includeSpread").onchange = historyStart.onchange = historyEnd.onchange = render;
|
||||||
[bandWindow, bandK, bandTrim].forEach(input => {{
|
[bandWindow, bandK, bandTrim].forEach(input => {{
|
||||||
input.addEventListener("keydown", ev => {{
|
input.addEventListener("keydown", ev => {{
|
||||||
if (ev.key === "Enter") {{
|
if (ev.key === "Enter") {{
|
||||||
@@ -608,12 +678,12 @@ function matches(c) {{
|
|||||||
const dim = dimSel.value;
|
const dim = dimSel.value;
|
||||||
const board = boardSel.value;
|
const board = boardSel.value;
|
||||||
if (board !== "全部" && !(c.boards || [c.board]).includes(board)) return false;
|
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 (dim !== "全部" && !c.dims[dim]?.score && c.dims[dim]?.score !== 0) return false;
|
||||||
if (!q) return true;
|
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();
|
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);
|
return hay.includes(q);
|
||||||
}}
|
}}
|
||||||
function activeDims() {{ return includeDemand.checked ? ["利润","产量","库存","需求"] : ["利润","产量","库存"]; }}
|
function activeDims() {{ const base = ["利润","产量","库存"]; if (includeDemand.checked) base.push("需求"); if (document.getElementById("includeSpread").checked) base.push("月差"); return base; }}
|
||||||
function currentDimScore(dim, v) {{
|
function currentDimScore(dim, v) {{
|
||||||
const dynamic = scoreFromCurrentBand(v, inverseDims.has(dim));
|
const dynamic = scoreFromCurrentBand(v, inverseDims.has(dim));
|
||||||
if (dynamic != null && Number.isFinite(dynamic)) return dynamic;
|
if (dynamic != null && Number.isFinite(dynamic)) return dynamic;
|
||||||
@@ -638,17 +708,21 @@ function rankedList(list) {{
|
|||||||
scored.forEach((c,i)=>c.displayRank=i+1);
|
scored.forEach((c,i)=>c.displayRank=i+1);
|
||||||
return scored.concat(mapped.filter(c => c.total == null));
|
return scored.concat(mapped.filter(c => c.total == null));
|
||||||
}}
|
}}
|
||||||
function topChips(list, reverse=false) {{
|
function topChips(list, cls) {{
|
||||||
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("");
|
return list.map(c => `<div class="chip ${{cls}}"><span>${{esc(c.name)}}</span><span style="margin-left:8px;">${{fmt(c.total)}}</span></div>`).join("");
|
||||||
}}
|
}}
|
||||||
function esc(s) {{ return String(s ?? "").replace(/[&<>"']/g, m => ({{"&":"&","<":"<",">":">",'"':""","'":"'"}}[m])); }}
|
function esc(s) {{ return String(s ?? "").replace(/[&<>"']/g, m => ({{"&":"&","<":"<",">":">",'"':""","'":"'"}}[m])); }}
|
||||||
function render() {{
|
function render() {{
|
||||||
const list = rankedList(DATA.commodities.filter(matches));
|
const list = rankedList(DATA.commodities.filter(matches));
|
||||||
const scored = list.filter(c => c.total != null);
|
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("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("coverage").textContent = `当前筛选覆盖 ${{scored.length}} 个有可用数据的品种;${{includeDemand.checked ? "需求参与总分" : "需求不参与总分"}};${{document.getElementById("includeSpread").checked ? "月差参与总分" : "月差不参与总分"}}。`;
|
||||||
document.getElementById("strong").innerHTML = topChips(scored, false) || `<span class="hint">暂无可用数据</span>`;
|
const sortedAll = scored.slice().sort((a,b)=>b.total-a.total);
|
||||||
document.getElementById("weak").innerHTML = topChips(scored, true) || `<span class="hint">暂无可用数据</span>`;
|
const splitIdx = Math.ceil(sortedAll.length / 2);
|
||||||
|
const strongList = sortedAll.slice(0, splitIdx);
|
||||||
|
const weakList = sortedAll.slice(splitIdx).reverse();
|
||||||
|
document.getElementById("strong").innerHTML = strongList.length ? topChips(strongList, "red") : `<span class="hint">暂无可用数据</span>`;
|
||||||
|
document.getElementById("weak").innerHTML = weakList.length ? topChips(weakList, "good") : `<span class="hint">暂无可用数据</span>`;
|
||||||
rowsEl.style.display = activeView === "dashboard" ? "block" : "none";
|
rowsEl.style.display = activeView === "dashboard" ? "block" : "none";
|
||||||
historyView.classList.toggle("active", activeView === "history");
|
historyView.classList.toggle("active", activeView === "history");
|
||||||
rowsEl.innerHTML = list.length ? list.map(renderCommodity).join("") : `<div class="empty">没有匹配的品种</div>`;
|
rowsEl.innerHTML = list.length ? list.map(renderCommodity).join("") : `<div class="empty">没有匹配的品种</div>`;
|
||||||
@@ -662,15 +736,26 @@ function render() {{
|
|||||||
if (activeView === "history") drawHistoryChart(list.filter(c => c.total != null));
|
if (activeView === "history") drawHistoryChart(list.filter(c => c.total != null));
|
||||||
}}
|
}}
|
||||||
function renderCommodity(c) {{
|
function renderCommodity(c) {{
|
||||||
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v)), renderMonthSpread()].join("");
|
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v))].join("");
|
||||||
const chartDims = dimSel.value === "全部" || dimSel.value === "月差" ? ["利润","产量","库存","需求"] : [dimSel.value];
|
const chartDims = dimSel.value === "全部" ? ["利润","产量","库存","需求"] : (dimSel.value === "月差" ? [] : [dimSel.value]);
|
||||||
const charts = chartDims.filter(d => d !== "月差").map(d => renderChartCell(c, d, c.dims[d])).join("");
|
const charts = chartDims.map(d => renderChartCell(c, d, c.dims[d])).join("");
|
||||||
|
const showSpread = dimSel.value === "全部" || dimSel.value === "月差";
|
||||||
|
let spreadCharts = "";
|
||||||
|
if (showSpread) {{
|
||||||
|
const ps = c.dims["月差"]?.pair_scores;
|
||||||
|
if (ps && ps.length) {{
|
||||||
|
spreadCharts = ps.filter(p => p.years).map((p, i) => renderPairChartCell(c, p, i)).join("");
|
||||||
|
}} else if (c.dims["月差"]?.years) {{
|
||||||
|
spreadCharts = renderChartCell(c, "月差", c.dims["月差"]);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
return `<section class="commodity">
|
return `<section class="commodity">
|
||||||
<div class="side">${{esc(c.name)}}</div>
|
<div class="side">${{esc(c.name)}}</div>
|
||||||
<div class="body">
|
<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="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="detail">季节性口径:最新值相对同期正常区间;库存反向,其余正向。有效维度:${{c.valid}}/${{c.denom}} 板块:${{esc(c.board)}}</div>
|
||||||
<div class="charts">${{charts}}</div>
|
${{charts ? `<div class="charts">${{charts}}</div>` : ""}}
|
||||||
|
${{spreadCharts ? `<div class="spread-charts">${{spreadCharts}}</div>` : ""}}
|
||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}}
|
}}
|
||||||
@@ -682,9 +767,6 @@ function renderDim(dim, v) {{
|
|||||||
<div class="dimhint">${{esc(ok ? v.direction : "缺数据/未配置")}}</div>
|
<div class="dimhint">${{esc(ok ? v.direction : "缺数据/未配置")}}</div>
|
||||||
</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) {{
|
function renderChartCell(c, dim, v) {{
|
||||||
const title = `${{esc(c.name)}}${{esc(dim)}}`;
|
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>`;
|
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>`;
|
||||||
@@ -695,6 +777,20 @@ function renderChartCell(c, dim, v) {{
|
|||||||
<svg data-chart='${{esc(JSON.stringify(v.years))}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
|
<svg data-chart='${{esc(JSON.stringify(v.years))}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
|
||||||
</div>`;
|
</div>`;
|
||||||
}}
|
}}
|
||||||
|
function renderPairChartCell(c, ps, idx) {{
|
||||||
|
const _pm = s => {{ const m = s.match(/\\d+$/); return m ? (parseInt(m[0]) % 100) : s; }};
|
||||||
|
const title = `${{esc(c.name)}}${{ps.pair_label.split(/\\s*-\\s*/).map(_pm).join("-")}}月差`;
|
||||||
|
const xStart = ps.x_start || "";
|
||||||
|
const xEnd = ps.x_end || "";
|
||||||
|
const yl = ps.year_labels || {{}};
|
||||||
|
const legendLabel = y => yl[y] || y;
|
||||||
|
return `<div class="chartcell">
|
||||||
|
<div class="chart-title">${{title}}</div>
|
||||||
|
<div class="metric-title">${{esc(ps.pair_label)}} 得分:${{fmt(ps.score)}}</div>
|
||||||
|
<div class="legend">${{Object.keys(ps.years).map(y=>`<span style="--c:${{yearColor(y, ps.years)}}">${{esc(legendLabel(y))}}</span>`).join("")}}</div>
|
||||||
|
<svg data-chart='${{esc(JSON.stringify(ps.years))}}' data-x-start='${{esc(xStart)}}' data-x-end='${{esc(xEnd)}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
|
||||||
|
</div>`;
|
||||||
|
}}
|
||||||
function parseDay(md) {{
|
function parseDay(md) {{
|
||||||
const [m,d] = md.split("-").map(Number);
|
const [m,d] = md.split("-").map(Number);
|
||||||
const date = new Date(2024, m-1, d);
|
const date = new Date(2024, m-1, d);
|
||||||
@@ -704,7 +800,7 @@ function parseDay(md) {{
|
|||||||
function chartParams() {{
|
function chartParams() {{
|
||||||
return {{
|
return {{
|
||||||
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
|
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
|
||||||
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
|
k: Math.max(0.5, Math.min(12, Number(bandK.value) || 3)),
|
||||||
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
|
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
|
||||||
startYear: Number(chartStartYear.value) || 2021,
|
startYear: Number(chartStartYear.value) || 2021,
|
||||||
excludeYear: maxChartYear,
|
excludeYear: maxChartYear,
|
||||||
@@ -787,18 +883,17 @@ function buildRawBand(years, rawPoints=null) {{
|
|||||||
const params = chartParams();
|
const params = chartParams();
|
||||||
const points = rawPoints || rawPointsFromYears(years);
|
const points = rawPoints || rawPointsFromYears(years);
|
||||||
const bandUniverse = points.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
|
const bandUniverse = points.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
|
||||||
const trendLike = trendLikeAnnualLevel(bandUniverse);
|
|
||||||
const band = [];
|
const band = [];
|
||||||
for (let day=1; day<=366; day++) {{
|
for (let day=1; day<=366; day++) {{
|
||||||
const samples = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
|
let samples = bandUniverse.filter(p => circularDiff(p.day, day) <= params.window).map(p => p.value).filter(Number.isFinite);
|
||||||
if (samples.length < 3) continue;
|
if (samples.length < 3) continue;
|
||||||
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
|
samples.sort((a,b)=>a-b);
|
||||||
const mid = quantile(samples, 0.50);
|
const mid = quantile(samples, 0.5);
|
||||||
let low = quantile(samples, tail);
|
const qLow = quantile(samples, params.trim);
|
||||||
let high = quantile(samples, 1 - tail);
|
const qHigh = quantile(samples, 1 - params.trim);
|
||||||
const widthScale = Math.max(0.25, Math.min(2.5, params.k / 3));
|
const widthScale = params.k / 3;
|
||||||
low = mid - (mid - low) * widthScale;
|
const low = mid - (mid - qLow) * widthScale;
|
||||||
high = mid + (high - mid) * widthScale;
|
const high = mid + (qHigh - mid) * widthScale;
|
||||||
const date = new Date(2024, 0, day);
|
const date = new Date(2024, 0, day);
|
||||||
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
|
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
|
||||||
band.push([md, low, high, mid]);
|
band.push([md, low, high, mid]);
|
||||||
@@ -871,39 +966,85 @@ function buildNormalizedChart(years) {{
|
|||||||
return {{years:normYears, band}};
|
return {{years:normYears, band}};
|
||||||
}}
|
}}
|
||||||
function drawChart(svg, years) {{
|
function drawChart(svg, years) {{
|
||||||
|
const xStartStr = svg.dataset.xStart || "";
|
||||||
|
const xEndStr = svg.dataset.xEnd || "";
|
||||||
|
const useCustomX = xStartStr && xEndStr;
|
||||||
const normalized = buildNormalizedChart(years);
|
const normalized = buildNormalizedChart(years);
|
||||||
years = normalized.years;
|
let chartYears = normalized.years;
|
||||||
const band = normalized.band;
|
let chartBand = normalized.band;
|
||||||
const W=520,H=230,L=44,R=8,T=16,B=30;
|
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);
|
let xStartDay, xEndDay, totalSpan, x;
|
||||||
|
if (useCustomX) {{
|
||||||
|
xStartDay = parseDay(xStartStr);
|
||||||
|
xEndDay = parseDay(xEndStr);
|
||||||
|
if (xStartDay <= xEndDay) totalSpan = xEndDay - xStartDay;
|
||||||
|
else totalSpan = (366 - xStartDay) + xEndDay;
|
||||||
|
if (totalSpan <= 0) totalSpan = 365;
|
||||||
|
x = d => {{ const day = parseDay(d); let off; if (day >= xStartDay) off = day - xStartDay; else off = (366 - xStartDay) + day; return L + off / totalSpan * (W-L-R); }};
|
||||||
|
}} else {{
|
||||||
|
x = d=> L + (parseDay(d)-1)/365*(W-L-R);
|
||||||
|
}}
|
||||||
|
// 自定义横坐标时,过滤超出生命周期范围的数据
|
||||||
|
if (useCustomX) {{
|
||||||
|
const visMin = L - 2, visMax = W - R + 2;
|
||||||
|
chartBand = chartBand.filter(p => x(p[0]) >= visMin && x(p[0]) <= visMax);
|
||||||
|
const filtYears = {{}};
|
||||||
|
Object.entries(chartYears).forEach(([yr, pts]) => {{
|
||||||
|
const f = pts.filter(p => x(p[0]) >= visMin && x(p[0]) <= visMax);
|
||||||
|
if (f.length) filtYears[yr] = f;
|
||||||
|
}});
|
||||||
|
chartYears = filtYears;
|
||||||
|
}}
|
||||||
|
// y轴范围
|
||||||
|
const bandValues = chartBand.flatMap(p => [p[1], p[2]]).filter(Number.isFinite);
|
||||||
|
const values = Object.values(chartYears).flat().map(p=>p[1]).filter(Number.isFinite).concat(bandValues);
|
||||||
if (!values.length) return;
|
if (!values.length) return;
|
||||||
let min=Math.min(...values), max=Math.max(...values);
|
let min=Math.min(...values), max=Math.max(...values);
|
||||||
if (min===max) {{ min-=1; max+=1; }}
|
if (min===max) {{ min-=1; max+=1; }}
|
||||||
const pad=(max-min)*0.12; min-=pad; max+=pad;
|
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);
|
const y=v=> T + (max-v)/(max-min)*(H-T-B);
|
||||||
let out = "";
|
let out = "";
|
||||||
for (let i=0;i<=4;i++) {{
|
for (let i=0;i<=4;i++) {{
|
||||||
const yy=T+i*(H-T-B)/4, val=max-i*(max-min)/4;
|
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>`;
|
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]]) {{
|
// x轴刻度
|
||||||
const xx=L+(day-1)/365*(W-L-R);
|
if (useCustomX) {{
|
||||||
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>`;
|
const tickMonths = [];
|
||||||
|
const sm = parseInt(xStartStr.split("-")[0]);
|
||||||
|
const em = parseInt(xEndStr.split("-")[0]);
|
||||||
|
let m = sm;
|
||||||
|
while (true) {{
|
||||||
|
tickMonths.push(m);
|
||||||
|
if (m === em) break;
|
||||||
|
m = m % 12 + 1;
|
||||||
|
if (tickMonths.length >= 12) break;
|
||||||
|
}}
|
||||||
|
for (const m of tickMonths) {{
|
||||||
|
const md = `${{String(m).padStart(2,"0")}}-15`;
|
||||||
|
const xx = x(md);
|
||||||
|
const label = `${{m}}月`;
|
||||||
|
out += `<line class="grid" x1="${{xx.toFixed(1)}}" y1="${{T}}" x2="${{xx.toFixed(1)}}" y2="${{H-B}}"></line><text x="${{(xx-10).toFixed(1)}}" y="${{H-9}}" font-size="12" fill="#667085">${{label}}</text>`;
|
||||||
|
}}
|
||||||
|
}} else {{
|
||||||
|
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>`;
|
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) {{
|
if (chartBand.length) {{
|
||||||
const sortedBand = band.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
|
const sortedBand = chartBand.slice().sort((a,b)=>x(a[0])-x(b[0]));
|
||||||
const upper = sortedBand.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[2]).toFixed(1)}}`).join(" ");
|
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(" ");
|
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>`;
|
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(" ");
|
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>`;
|
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]) => {{
|
Object.entries(chartYears).forEach(([year, pts]) => {{
|
||||||
const color=yearColor(year, years);
|
const color=yearColor(year, chartYears);
|
||||||
const sorted=pts.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
|
const sorted=pts.slice().sort((a,b)=>x(a[0])-x(b[0]));
|
||||||
const d=sorted.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[1]).toFixed(1)}}`).join(" ");
|
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>`;
|
out += `<path d="${{d}}" fill="none" stroke="${{color}}" stroke-width="2.2" vector-effect="non-scaling-stroke"></path>`;
|
||||||
}});
|
}});
|
||||||
@@ -929,10 +1070,18 @@ function drawHistoryChart(list) {{
|
|||||||
const start = historyStart.value || "2026-01-01";
|
const start = historyStart.value || "2026-01-01";
|
||||||
const end = historyEnd.value || todayString();
|
const end = historyEnd.value || todayString();
|
||||||
const useDemand = includeDemand.checked;
|
const useDemand = includeDemand.checked;
|
||||||
|
const useSpread = document.getElementById("includeSpread").checked;
|
||||||
const series = list.map(c => {{
|
const series = list.map(c => {{
|
||||||
const pts = (c.scoreHistory || [])
|
const pts = (c.scoreHistory || [])
|
||||||
.filter(p => p.date >= start && p.date <= end)
|
.filter(p => p.date >= start && p.date <= end)
|
||||||
.map(p => [p.date, useDemand ? p.total : p.totalNoDemand])
|
.map(p => {{
|
||||||
|
let val;
|
||||||
|
if (useDemand && useSpread) val = p.total;
|
||||||
|
else if (useDemand && !useSpread) val = p.totalNoDemandNoSpread != null ? (p.totalNoDemand != null ? p.totalNoDemand : p.total) : p.totalNoDemand;
|
||||||
|
else if (!useDemand && useSpread) val = p.totalNoDemand;
|
||||||
|
else val = p.totalNoDemandNoSpread != null ? p.totalNoDemandNoSpread : p.totalNoDemand;
|
||||||
|
return [p.date, val];
|
||||||
|
}})
|
||||||
.filter(p => p[1] != null && Number.isFinite(Number(p[1])));
|
.filter(p => p[1] != null && Number.isFinite(Number(p[1])));
|
||||||
return {{name:c.name, points:pts}};
|
return {{name:c.name, points:pts}};
|
||||||
}}).filter(s => s.points.length >= 2);
|
}}).filter(s => s.points.length >= 2);
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
月差数据库计算模块
|
||||||
|
基于 MySQL contract_day 表动态计算月差对,取代静态 Excel 读取。
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
- 以最新日期确定固定合约对(近月=持仓top3 OI不含远月,远月=最远OI>1万合约)
|
||||||
|
- 历史序列只取该固定合约对的价差,确保同一合约对连续走势
|
||||||
|
- 若标准月差对(OI>1万)无法形成,使用主次月差对(持仓前2名合约)
|
||||||
|
- 每月差对独立评分(seasonal band_score),考虑生命周期窗口
|
||||||
|
- 品种月差分 = 所有有效月差对得分平均
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 导入 MySQL 客户端
|
||||||
|
SKILL_DIR = Path(os.path.expanduser("~/.workbuddy/skills/contract_day/scripts"))
|
||||||
|
sys.path.insert(0, str(SKILL_DIR))
|
||||||
|
from mysql_client import MysqlDLLClient
|
||||||
|
|
||||||
|
# ---- 品种代码映射 -----------------------------------------------------------
|
||||||
|
|
||||||
|
# 基本面指标确认中的品种名 -> 品种代码
|
||||||
|
NAME_TO_PCODE: dict[str, str] = {
|
||||||
|
"玉米": "c", "淀粉": "cs", "豆一": "a", "豆二": "b", "豆粕": "m",
|
||||||
|
"豆油": "y", "棕榈油": "p", "鸡蛋": "jd", "生猪": "lh", "塑料": "l",
|
||||||
|
"PVC": "v", "聚丙烯": "pp", "焦炭": "j", "焦煤": "jm", "铁矿": "i",
|
||||||
|
"乙二醇": "eg", "苯乙烯": "eb", "液化气": "pg", "棉花": "cf",
|
||||||
|
"白糖": "sr", "菜籽油": "oi", "菜籽粕": "rm", "苹果": "ap",
|
||||||
|
"红枣": "cj", "花生": "pk", "PTA": "ta", "甲醇": "ma", "玻璃": "fg",
|
||||||
|
"硅铁": "sf", "锰硅": "sm", "尿素": "ur", "纯碱": "sa", "短纤": "pf",
|
||||||
|
"沪铜": "cu", "沪铝": "al", "沪锌": "zn", "沪铅": "pb", "沪镍": "ni",
|
||||||
|
"沪锡": "sn", "沪金": "au", "沪银": "ag", "螺纹": "rb", "热卷": "hc",
|
||||||
|
"不锈钢": "ss", "纸浆": "sp", "燃油": "fu", "沥青": "bu", "橡胶": "ru",
|
||||||
|
"20号胶": "nr", "原油": "sc", "LU燃油": "lu", "IF": "if", "IC": "ic",
|
||||||
|
"IM": "im", "IH": "ih", "二年债": "ts", "五年债": "tf", "十年债": "t",
|
||||||
|
"工业硅": "si", "氧化铝": "ao", "碳酸锂": "lc", "三十年债": "tl",
|
||||||
|
"BR橡胶": "br", "烧碱": "sh", "对二甲苯": "px", "欧线": "ec",
|
||||||
|
"瓶片": "pr", "原木": "lg", "铂": "pt", "钯": "pd", "丙烯": "pl",
|
||||||
|
"纯苯": "bz", "国际铜": "op", "动力煤": "zc",
|
||||||
|
"多晶硅": "ps", "铝合金": "ad",
|
||||||
|
}
|
||||||
|
PCODE_TO_NAME: dict[str, str] = {v: k for k, v in NAME_TO_PCODE.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 数据库查询 -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_one(db: MysqlDLLClient, p_code: str, start: str = "2018-01-01") -> pd.DataFrame:
|
||||||
|
"""查询单品种全合约历史(内部函数,需传入已打开的 db)。"""
|
||||||
|
try:
|
||||||
|
rows = db.query(
|
||||||
|
1,
|
||||||
|
"SELECT times, contract, close, oi FROM contract_day "
|
||||||
|
"WHERE p_code = ? AND times >= ? ORDER BY times, contract",
|
||||||
|
[p_code, start],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return pd.DataFrame(columns=["times", "contract", "close", "oi"])
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=["times", "contract", "close", "oi"])
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df["times"] = pd.to_datetime(df["times"])
|
||||||
|
df["close"] = pd.to_numeric(df["close"], errors="coerce")
|
||||||
|
df["oi"] = pd.to_numeric(df["oi"], errors="coerce")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def get_contract_lifecycles(df: pd.DataFrame) -> dict[str, tuple[pd.Timestamp, pd.Timestamp]]:
|
||||||
|
"""获取每个合约的生命周期:(首次交易日, 最后交易日)。"""
|
||||||
|
life = {}
|
||||||
|
for contract, grp in df.groupby("contract"):
|
||||||
|
life[contract] = (grp["times"].min(), grp["times"].max())
|
||||||
|
return life
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 月差对计算 --------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_spread_pairs_for_date(date_df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
计算某一天的所有有效月差对。
|
||||||
|
"""
|
||||||
|
active = date_df[date_df["oi"] > 10000].copy()
|
||||||
|
if len(active) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
active = active.sort_values("contract")
|
||||||
|
far_row = active.iloc[-1] # 远月:排序最靠后
|
||||||
|
|
||||||
|
near_candidates = active[active["contract"] != far_row["contract"]].copy()
|
||||||
|
if near_candidates.empty:
|
||||||
|
return []
|
||||||
|
near_top3 = near_candidates.nlargest(min(3, len(near_candidates)), "oi")
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
for _, near_row in near_top3.iterrows():
|
||||||
|
pairs.append({
|
||||||
|
"near": near_row["contract"],
|
||||||
|
"far": far_row["contract"],
|
||||||
|
"near_close": float(near_row["close"]),
|
||||||
|
"far_close": float(far_row["close"]),
|
||||||
|
"spread": float(near_row["close"]) - float(far_row["close"]),
|
||||||
|
})
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def compute_fallback_pairs_for_date(date_df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
计算某一天的月差对(低门槛版本)。
|
||||||
|
当标准月差对(OI>1万)无法形成时,取持仓前两名的合约按先后顺序形成近月-远月月差对。
|
||||||
|
"""
|
||||||
|
active = date_df[date_df["oi"] > 0].copy()
|
||||||
|
if len(active) < 2:
|
||||||
|
return []
|
||||||
|
# 按持仓排序,取前两名
|
||||||
|
top2 = active.nlargest(2, "oi")
|
||||||
|
if len(top2) < 2:
|
||||||
|
return []
|
||||||
|
# 按合约代码排序(时间顺序),前者为近月,后者为远月
|
||||||
|
top2_sorted = top2.sort_values("contract")
|
||||||
|
near_row = top2_sorted.iloc[0]
|
||||||
|
far_row = top2_sorted.iloc[1]
|
||||||
|
return [{
|
||||||
|
"near": near_row["contract"],
|
||||||
|
"far": far_row["contract"],
|
||||||
|
"near_close": float(near_row["close"]),
|
||||||
|
"far_close": float(far_row["close"]),
|
||||||
|
"spread": float(near_row["close"]) - float(far_row["close"]),
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 辅助函数:提取合约月份 -----------------------------------------------
|
||||||
|
|
||||||
|
def _extract_contract_month(contract: str) -> int | None:
|
||||||
|
"""从合约代码提取月份。支持4位(如 RB2610→10)和3位(如 MA609→9)格式。"""
|
||||||
|
m = _re.match(r"\D+(\d{2})(\d{2})$", contract)
|
||||||
|
if m:
|
||||||
|
return int(m.group(2))
|
||||||
|
m = _re.match(r"\D+(\d)(\d{2})$", contract)
|
||||||
|
if m:
|
||||||
|
return int(m.group(2))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_month_pair(near: str, far: str) -> tuple[int, int] | None:
|
||||||
|
"""从近月/远月合约代码提取月份组合,如 (EG2609, EG2701) → (9, 1)。"""
|
||||||
|
nm = _extract_contract_month(near)
|
||||||
|
fm = _extract_contract_month(far)
|
||||||
|
if nm is not None and fm is not None:
|
||||||
|
return (nm, fm)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_prefix(contract: str) -> int | None:
|
||||||
|
"""从合约代码提取年份前缀(数字部分去掉末2位月份)。
|
||||||
|
RB2610 → 26, MA609 → 6, SC2608 → 26。"""
|
||||||
|
digits = "".join(c for c in contract if c.isdigit())
|
||||||
|
if len(digits) < 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(digits[:-2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _year_gap(near: str, far: str) -> int | None:
|
||||||
|
"""计算近月/远月合约的年份间隔,如 (SC2608, SC2609) → 0, (EG2609, EG2701) → 1。"""
|
||||||
|
ny = _extract_year_prefix(near)
|
||||||
|
fy = _extract_year_prefix(far)
|
||||||
|
if ny is not None and fy is not None:
|
||||||
|
return fy - ny
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 历史月差序列构建 --------------------------------------------------------
|
||||||
|
|
||||||
|
def build_historical_spreads(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
latest_pairs: list[dict[str, Any]] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
构建该品种所有有效月差对的历史时间序列(含往年同期月差对)。
|
||||||
|
对每个最新月差对,提取月份组合(如9-1),然后扫描所有合约找出
|
||||||
|
历史上同一月份组合的合约对,各自独立计算价差序列。
|
||||||
|
返回每项含 hist_series 列表,每个元素含 pair_label / series / year。
|
||||||
|
"""
|
||||||
|
if not latest_pairs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_contracts = df["contract"].unique().tolist()
|
||||||
|
start_year = 2021
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, pair in enumerate(latest_pairs):
|
||||||
|
near_contract = pair["near"]
|
||||||
|
far_contract = pair["far"]
|
||||||
|
type_label = f"N{i+1}"
|
||||||
|
pair_label = f"{near_contract} - {far_contract}"
|
||||||
|
|
||||||
|
# 提取月份组合
|
||||||
|
month_pair = _extract_month_pair(near_contract, far_contract)
|
||||||
|
if month_pair is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
near_month, far_month = month_pair
|
||||||
|
|
||||||
|
# 提取年份间隔(关键过滤:只保留与最新对同年隔的历史对)
|
||||||
|
ref_gap = _year_gap(near_contract, far_contract)
|
||||||
|
|
||||||
|
# 找出所有与该月份组合匹配的历史合约对
|
||||||
|
hist_series_list = []
|
||||||
|
for c1 in all_contracts:
|
||||||
|
m1 = _extract_contract_month(c1)
|
||||||
|
if m1 != near_month:
|
||||||
|
continue
|
||||||
|
for c2 in all_contracts:
|
||||||
|
m2 = _extract_contract_month(c2)
|
||||||
|
if m2 != far_month:
|
||||||
|
continue
|
||||||
|
# c1 应早于 c2(近月合约代码 ≤ 远月合约代码)
|
||||||
|
if c1 >= c2:
|
||||||
|
continue
|
||||||
|
# 年份间隔过滤:只保留与最新月差对同年隔的合约对
|
||||||
|
# 如最新对 SC2608-SC2609(0年隔),则排除 SC2108-SC2209(1年隔)等
|
||||||
|
if ref_gap is not None:
|
||||||
|
gap = _year_gap(c1, c2)
|
||||||
|
if gap is not None and gap != ref_gap:
|
||||||
|
continue
|
||||||
|
# 取两个合约的价差序列
|
||||||
|
near_data = df[df["contract"] == c1][["times", "close"]].rename(columns={"close": "near_close"})
|
||||||
|
far_data = df[df["contract"] == c2][["times", "close"]].rename(columns={"close": "far_close"})
|
||||||
|
merged = near_data.merge(far_data, on="times", how="inner")
|
||||||
|
if merged.empty:
|
||||||
|
continue
|
||||||
|
merged = merged.sort_values("times")
|
||||||
|
spread_series = pd.Series(
|
||||||
|
(merged["near_close"] - merged["far_close"]).values,
|
||||||
|
index=merged["times"],
|
||||||
|
name="spread",
|
||||||
|
)
|
||||||
|
spread_series = spread_series[~spread_series.index.duplicated(keep="last")]
|
||||||
|
if spread_series.dropna().empty:
|
||||||
|
continue
|
||||||
|
# 只保留 start_year 之后的数据
|
||||||
|
spread_series = spread_series[spread_series.index >= f"{start_year}-01-01"]
|
||||||
|
if spread_series.dropna().empty:
|
||||||
|
continue
|
||||||
|
# 推断年份:取序列中间日期的年份
|
||||||
|
mid_idx = len(spread_series) // 2
|
||||||
|
year_val = int(spread_series.index[mid_idx].year)
|
||||||
|
hist_series_list.append({
|
||||||
|
"pair_label": f"{c1} - {c2}",
|
||||||
|
"series": spread_series,
|
||||||
|
"year": year_val,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按年份排序
|
||||||
|
hist_series_list.sort(key=lambda x: x["year"])
|
||||||
|
|
||||||
|
if not hist_series_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"type_label": type_label,
|
||||||
|
"pair_label": pair_label,
|
||||||
|
"hist_series": hist_series_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 使用主脚本的 band_score 进行评分 -------------------------------------
|
||||||
|
|
||||||
|
# band_score 函数由 generate_fundamental_dashboard.py 提供,这里导入复用
|
||||||
|
# 若独立运行,使用内置简化版
|
||||||
|
|
||||||
|
try:
|
||||||
|
from generate_fundamental_dashboard import band_score as _band_score
|
||||||
|
_USE_EXTERNAL_BAND = True
|
||||||
|
except ImportError:
|
||||||
|
_USE_EXTERNAL_BAND = False
|
||||||
|
|
||||||
|
|
||||||
|
def score_spread_pair(
|
||||||
|
value: float,
|
||||||
|
history: pd.Series,
|
||||||
|
current_date: pd.Timestamp,
|
||||||
|
inverse: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""使用标准 band_score 对月差对评分。"""
|
||||||
|
import math
|
||||||
|
history = history.dropna()
|
||||||
|
if history.empty:
|
||||||
|
return None
|
||||||
|
day = int(current_date.dayofyear)
|
||||||
|
if _USE_EXTERNAL_BAND:
|
||||||
|
result = _band_score(value, history[history.index < current_date], day, inverse)
|
||||||
|
else:
|
||||||
|
# 内置简化版
|
||||||
|
result = _builtin_band_score(value, history, day, inverse)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _builtin_band_score(value: float, history: pd.Series, day: int, inverse: bool) -> dict | None:
|
||||||
|
"""内置简化版 band_score(独立运行时使用)。使用分位数方法,与主模块一致。"""
|
||||||
|
import math
|
||||||
|
history = history.dropna().sort_index()
|
||||||
|
if len(history) < 10:
|
||||||
|
return None
|
||||||
|
# day-of-year 窗口采样
|
||||||
|
days = history.index.dayofyear.to_numpy()
|
||||||
|
day_diff = np.abs(days - day)
|
||||||
|
day_diff = np.minimum(day_diff, 366 - day_diff)
|
||||||
|
sample = history[day_diff <= 15]
|
||||||
|
if len(sample) < 8:
|
||||||
|
sample = history[day_diff <= 30]
|
||||||
|
if len(sample) < 4:
|
||||||
|
return None
|
||||||
|
sample = sample.sort_values()
|
||||||
|
trim_n = int(len(sample) * 0.1)
|
||||||
|
trimmed_s = sample.iloc[trim_n : len(sample) - trim_n] if trim_n > 0 else sample
|
||||||
|
mid = float(trimmed_s.quantile(0.50))
|
||||||
|
tail = max(0.02, min(0.45, 0.10))
|
||||||
|
q_low = float(trimmed_s.quantile(tail))
|
||||||
|
q_high = float(trimmed_s.quantile(1 - tail))
|
||||||
|
width_scale = max(0.25, min(4, 3.0 / 3))
|
||||||
|
low = mid - (mid - q_low) * width_scale
|
||||||
|
high = mid + (q_high - mid) * width_scale
|
||||||
|
if high == low:
|
||||||
|
score = 50.0
|
||||||
|
elif inverse:
|
||||||
|
score = 100.0 if value <= low else (0.0 if value >= high else 100.0 - (value - low) / (high - low) * 100)
|
||||||
|
else:
|
||||||
|
score = 100.0 if value >= high else (0.0 if value <= low else (value - low) / (high - low) * 100)
|
||||||
|
below = float((sample < value).sum())
|
||||||
|
equal = float((sample == value).sum())
|
||||||
|
raw_pct = 100.0 * (below + 0.5 * equal) / len(sample)
|
||||||
|
return {
|
||||||
|
"score": round(max(0.0, min(100.0, score)), 1),
|
||||||
|
"raw_percentile": round(raw_pct, 1),
|
||||||
|
"band_low": round(low, 4),
|
||||||
|
"band_high": round(high, 4),
|
||||||
|
"band_mean": round(mid, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 月差图表时间序列(用于 seasonal_percentile 兼容) ---------------------
|
||||||
|
|
||||||
|
def build_spread_seasonal_data(
|
||||||
|
combined_series: pd.Series,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""将合并月差序列转为与 seasonal_percentile() 兼容的格式。"""
|
||||||
|
s = combined_series.dropna().sort_index()
|
||||||
|
if s.empty:
|
||||||
|
return None
|
||||||
|
latest_date = s.index.max()
|
||||||
|
latest_value = float(s.loc[latest_date])
|
||||||
|
yearly: dict[str, list[tuple[str, float]]] = {}
|
||||||
|
clipped = s[s.index.year >= 2021]
|
||||||
|
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,
|
||||||
|
"years": yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_spread_seasonal_data_multi(
|
||||||
|
hist_series_list: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
将多月差历史序列转为前端绘图格式。
|
||||||
|
years 的键用年份字符串(如 "2026"),同时提供 year_labels 映射
|
||||||
|
年份→合约对标签(如 "2026" → "EG2609-EG2701")。
|
||||||
|
"""
|
||||||
|
if not hist_series_list:
|
||||||
|
return None
|
||||||
|
|
||||||
|
yearly: dict[str, list[tuple[str, float]]] = {}
|
||||||
|
year_labels: dict[str, str] = {}
|
||||||
|
latest_date = None
|
||||||
|
latest_value = None
|
||||||
|
|
||||||
|
for hs in hist_series_list:
|
||||||
|
s = hs["series"].dropna().sort_index()
|
||||||
|
if s.empty:
|
||||||
|
continue
|
||||||
|
year_str = str(hs["year"])
|
||||||
|
# 图例只保留年份:EG2609-EG2701 → 26-27,MA609-MA701 → 6-7
|
||||||
|
pair_label = hs["pair_label"].replace(" ", "")
|
||||||
|
parts = pair_label.split("-")
|
||||||
|
short_parts = ["".join(c for c in p if c.isdigit())[:-2] for p in parts]
|
||||||
|
short_label = "-".join(short_parts)
|
||||||
|
year_labels[year_str] = short_label
|
||||||
|
for dt, val in s.items():
|
||||||
|
yearly.setdefault(year_str, []).append((dt.strftime("%m-%d"), float(val)))
|
||||||
|
# 记录最新值(取最末序列的末尾值)
|
||||||
|
if latest_date is None or s.index.max() > latest_date:
|
||||||
|
latest_date = s.index.max()
|
||||||
|
latest_value = float(s.loc[latest_date])
|
||||||
|
|
||||||
|
if not yearly:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest_date": latest_date.strftime("%Y-%m-%d") if latest_date else "",
|
||||||
|
"latest_value": latest_value,
|
||||||
|
"years": yearly,
|
||||||
|
"year_labels": year_labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 从 pair_label 提取横坐标范围 --------------------------------------------
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
def _extract_x_range(pair_label: str) -> tuple[str, str] | None:
|
||||||
|
"""
|
||||||
|
从月差对标签提取横坐标范围,如 "RB2610 - RB2705" → ("05-15", "10-15")。
|
||||||
|
规则:x_start = 远月合约月份15日,x_end = 近月合约月份15日。
|
||||||
|
支持4位数字(大商所/上期所/中金所)和3位数字(郑商所)格式。
|
||||||
|
"""
|
||||||
|
# 4位数字格式(同品种代码): e.g., RB2610 - RB2705
|
||||||
|
m = _re.match(r"(\D+)(\d{2})(\d{2})\s*-\s*\1(\d{2})(\d{2})", pair_label)
|
||||||
|
if m:
|
||||||
|
near_month = int(m.group(3))
|
||||||
|
far_month = int(m.group(5))
|
||||||
|
return (f"{far_month:02d}-15", f"{near_month:02d}-15")
|
||||||
|
# 3位数字格式(郑商所,同品种代码): e.g., MA609 - MA701
|
||||||
|
m = _re.match(r"(\D+)(\d)(\d{2})\s*-\s*\1(\d)(\d{2})", pair_label)
|
||||||
|
if m:
|
||||||
|
near_month = int(m.group(3))
|
||||||
|
far_month = int(m.group(5))
|
||||||
|
return (f"{far_month:02d}-15", f"{near_month:02d}-15")
|
||||||
|
# 4位数字格式(品种代码不同): e.g., IF2612 - IH2703
|
||||||
|
m = _re.match(r"(\D+)(\d{2})(\d{2})\s*-\s*(\D+)(\d{2})(\d{2})", pair_label)
|
||||||
|
if m:
|
||||||
|
near_month = int(m.group(3))
|
||||||
|
far_month = int(m.group(6))
|
||||||
|
return (f"{far_month:02d}-15", f"{near_month:02d}-15")
|
||||||
|
# 3位数字格式(品种代码不同)
|
||||||
|
m = _re.match(r"(\D+)(\d)(\d{2})\s*-\s*(\D+)(\d)(\d{2})", pair_label)
|
||||||
|
if m:
|
||||||
|
near_month = int(m.group(3))
|
||||||
|
far_month = int(m.group(6))
|
||||||
|
return (f"{far_month:02d}-15", f"{near_month:02d}-15")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 主入口函数(批量模式)--------------------------------------------------
|
||||||
|
|
||||||
|
def read_spread_from_db_batch(
|
||||||
|
variety_names: list[str],
|
||||||
|
start_date: str = "2018-01-01",
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
批量从 MySQL 读取所有品种月差数据并评分(复用同一 DB 连接)。
|
||||||
|
|
||||||
|
返回 {品种名: {score, pair_scores, series, latest_date, latest_pairs}}
|
||||||
|
"""
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
with MysqlDLLClient() as db:
|
||||||
|
# 先查出全局最新日期
|
||||||
|
try:
|
||||||
|
max_date_rows = db.query(
|
||||||
|
1,
|
||||||
|
"SELECT MAX(times) as max_dt FROM contract_day WHERE times >= ?",
|
||||||
|
["2026-01-01"],
|
||||||
|
)
|
||||||
|
global_latest = pd.Timestamp(max_date_rows[0]["max_dt"]) if max_date_rows else None
|
||||||
|
except Exception:
|
||||||
|
global_latest = None
|
||||||
|
|
||||||
|
for name in variety_names:
|
||||||
|
p_code = NAME_TO_PCODE.get(name)
|
||||||
|
if not p_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = _fetch_one(db, p_code, start_date)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if raw.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 确定最新日期
|
||||||
|
latest_date_actual = global_latest or raw["times"].max()
|
||||||
|
latest_df = raw[raw["times"] == latest_date_actual]
|
||||||
|
if latest_df.empty:
|
||||||
|
for d in sorted(raw["times"].unique(), reverse=True):
|
||||||
|
if not raw[raw["times"] == d].empty:
|
||||||
|
latest_date_actual = d
|
||||||
|
latest_df = raw[raw["times"] == d]
|
||||||
|
break
|
||||||
|
|
||||||
|
if latest_df.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
latest_pairs = compute_spread_pairs_for_date(latest_df)
|
||||||
|
# 标准月差对(OI>1万)无法形成时,使用主次月差对(持仓前2名)
|
||||||
|
if not latest_pairs:
|
||||||
|
latest_pairs = compute_fallback_pairs_for_date(latest_df)
|
||||||
|
hist_spreads = build_historical_spreads(raw, latest_pairs)
|
||||||
|
|
||||||
|
# 对每月差对评分(最多3对)
|
||||||
|
pair_scores = []
|
||||||
|
valid_scores = []
|
||||||
|
for hs in hist_spreads[:3]:
|
||||||
|
# 从 hist_series 中找最新合约对(最后一个)的最新值
|
||||||
|
latest_hist = None
|
||||||
|
for h in reversed(hs["hist_series"]):
|
||||||
|
if latest_date_actual in h["series"].index:
|
||||||
|
latest_hist = h
|
||||||
|
break
|
||||||
|
if latest_hist is None:
|
||||||
|
continue
|
||||||
|
latest_val = latest_hist["series"].loc[latest_date_actual]
|
||||||
|
if pd.isna(latest_val):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 评分:合并所有历史序列作为评分参考
|
||||||
|
all_series = pd.concat([h["series"] for h in hs["hist_series"]]).sort_index()
|
||||||
|
all_series = all_series[~all_series.index.duplicated(keep="last")]
|
||||||
|
|
||||||
|
sr = score_spread_pair(
|
||||||
|
value=latest_val,
|
||||||
|
history=all_series,
|
||||||
|
current_date=latest_date_actual,
|
||||||
|
inverse=False,
|
||||||
|
)
|
||||||
|
if sr is None:
|
||||||
|
continue
|
||||||
|
# 统一四舍五入
|
||||||
|
sr_rounded = {k: (round(v, 1) if isinstance(v, float) else v) for k, v in sr.items()}
|
||||||
|
pair_entry = {
|
||||||
|
"pair_label": hs["pair_label"],
|
||||||
|
"type_label": hs["type_label"],
|
||||||
|
"latest_value": round(float(latest_val), 4),
|
||||||
|
**sr_rounded,
|
||||||
|
}
|
||||||
|
# 每个月差对独立生成季节性图数据(多历史序列)
|
||||||
|
pair_seasonal = build_spread_seasonal_data_multi(hs["hist_series"])
|
||||||
|
if pair_seasonal:
|
||||||
|
pair_entry["years"] = pair_seasonal["years"]
|
||||||
|
if pair_seasonal.get("year_labels"):
|
||||||
|
pair_entry["year_labels"] = pair_seasonal["year_labels"]
|
||||||
|
# 从 pair_label 提取合约月份,计算横坐标范围
|
||||||
|
x_range = _extract_x_range(hs["pair_label"])
|
||||||
|
if x_range:
|
||||||
|
pair_entry["x_start"] = x_range[0]
|
||||||
|
pair_entry["x_end"] = x_range[1]
|
||||||
|
pair_scores.append(pair_entry)
|
||||||
|
if sr_rounded.get("score") is not None:
|
||||||
|
valid_scores.append(sr_rounded["score"])
|
||||||
|
|
||||||
|
total_score = round(float(np.mean(valid_scores)), 1) if valid_scores else None
|
||||||
|
|
||||||
|
# 合并月差序列(取中位数)
|
||||||
|
combined_series = _combine_spread_series(hist_spreads)
|
||||||
|
if not combined_series.empty:
|
||||||
|
combined_series = combined_series[combined_series.index >= "2021-01-01"]
|
||||||
|
|
||||||
|
result[name] = {
|
||||||
|
"score": total_score,
|
||||||
|
"pair_scores": pair_scores,
|
||||||
|
"series": combined_series,
|
||||||
|
"latest_date": latest_date_actual.strftime("%Y-%m-%d") if latest_date_actual else "",
|
||||||
|
"latest_pairs": [
|
||||||
|
{"near": p["near"], "far": p["far"], "spread": round(p["spread"], 4)}
|
||||||
|
for p in latest_pairs
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_spread_series(hist_spreads: list[dict]) -> pd.Series:
|
||||||
|
"""合并多月差序列(取中位数)。适配 hist_series 新格式。"""
|
||||||
|
if not hist_spreads:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
dfs = []
|
||||||
|
for hs in hist_spreads:
|
||||||
|
# 新格式:hist_series 列表
|
||||||
|
for h in hs.get("hist_series", []):
|
||||||
|
s = h["series"].rename(hs["type_label"])
|
||||||
|
dfs.append(s)
|
||||||
|
if not dfs:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
combined = pd.concat(dfs, axis=1)
|
||||||
|
combined["median"] = combined.median(axis=1, skipna=True)
|
||||||
|
return combined["median"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 测试入口 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
result = read_spread_from_db_batch(["螺纹", "沪铜", "沪金"])
|
||||||
|
for name, data in result.items():
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"品种: {name} 总得分: {data['score']}")
|
||||||
|
print(f"最新日期: {data['latest_date']}")
|
||||||
|
print(f"月差对:")
|
||||||
|
for ps in data["pair_scores"]:
|
||||||
|
print(f" {ps['pair_label']}: 值={ps['latest_value']}, 分={ps['score']}, "
|
||||||
|
f"区间=[{ps['band_low']}, {ps['band_high']}]")
|
||||||
+132
-49
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user