评分改为±100分制;库存图y轴倒置;月差序列过滤远月前12月幽灵数据

This commit is contained in:
2026-06-15 15:25:26 +08:00
parent 94a230e485
commit bc9f2682d1
4 changed files with 93 additions and 51 deletions
+30 -25
View File
@@ -198,16 +198,16 @@ def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool
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
raw_score = 100.0 if latest_value >= high else -100.0
elif latest_value >= high:
raw_score = 100.0
elif latest_value <= low:
raw_score = 0.0
raw_score = -100.0
else:
raw_score = 100.0 * (latest_value - low) / (high - low)
score = 100.0 - raw_score if inverse else raw_score
raw_score = 200.0 * (latest_value - low) / (high - low) - 100.0
score = -raw_score if inverse else raw_score
return {
"score": max(0.0, min(100.0, score)),
"score": max(-100.0, min(100.0, score)),
"band_low": low,
"band_high": high,
"band_mean": mid,
@@ -279,7 +279,8 @@ def score_series_at(series: pd.Series, date: pd.Timestamp, inverse: bool = False
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
raw_mapped = raw * 2.0 - 100.0
return -raw_mapped if inverse else raw_mapped
def build_score_history(dim_series: dict[str, pd.Series]) -> list[dict[str, Any]]:
@@ -351,7 +352,8 @@ def make_records() -> dict[str, Any]:
stat.update(band)
else:
raw = stat["raw_percentile"]
score = 100.0 - raw if dim in INVERSE_DIMS else raw
raw_mapped = raw * 2.0 - 100.0
score = -raw_mapped if dim in INVERSE_DIMS else raw_mapped
stat["score"] = round(score, 1)
stat["direction"] = "低库存更优,按正常区间反向计分" if dim in INVERSE_DIMS else "高于正常区间更优"
stat["expr"] = str(row.get(dim, "")).strip()
@@ -389,7 +391,8 @@ def make_records() -> dict[str, Any]:
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
raw_mapped = raw * 2.0 - 100.0
score = -raw_mapped if dim in INVERSE_DIMS else raw_mapped
stat["score"] = round(score, 1)
stat["direction"] = "高于正常区间更优(近月升水=强势)"
stat["expr"] = "近月(top3 OI) - 远月(最远>1万手)"
@@ -585,7 +588,7 @@ svg {{ width:100%; height:230px; display:block; }}
<div class="chips" id="weak"></div>
</div>
</section>
<div class="note">维度得分按当前阴影带范围计算(均值±K×标准差):正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。调整顶部「带宽K」可实时改变阴影带宽度和得分。</div>
<div class="note">维度得分采用 ±100 分制:高于季节性正常区间上沿为 +100,低于下沿为 -100,区间内线性映射;0 分表示处于正常中位水平。正得分偏利多,负得分偏利空;库存反向。调整顶部「带宽K」可实时改变阴影带宽度和得分。</div>
<main class="rows" id="rows"></main>
<section class="history-view" id="historyView">
<div class="history-tools">
@@ -718,9 +721,8 @@ function render() {{
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("includeSpread").checked ? "月差参与总分" : "月差不参与总分"}}。`;
const sortedAll = scored.slice().sort((a,b)=>b.total-a.total);
const splitIdx = Math.ceil(sortedAll.length / 2);
const strongList = sortedAll.slice(0, splitIdx);
const weakList = sortedAll.slice(splitIdx).reverse();
const strongList = sortedAll.filter(c => c.total > 0);
const weakList = sortedAll.filter(c => c.total < 0).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";
@@ -774,7 +776,7 @@ function renderChartCell(c, dim, v) {{
<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>
<svg data-chart='${{esc(JSON.stringify(v.years))}}' data-dim='${{esc(dim)}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
</div>`;
}}
function renderPairChartCell(c, ps, idx) {{
@@ -917,12 +919,12 @@ function scoreFromCurrentBand(v, inverse=false) {{
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low > high) {{ const tmp = low; low = high; high = tmp; }}
let rawScore;
if (high === low) rawScore = latest.value >= high ? 100 : 0;
if (high === low) rawScore = latest.value >= high ? 100 : -100;
else if (latest.value >= high) rawScore = 100;
else if (latest.value <= low) rawScore = 0;
else rawScore = 100 * (latest.value - low) / (high - low);
const score = inverse ? 100 - rawScore : rawScore;
return Math.max(0, Math.min(100, score));
else if (latest.value <= low) rawScore = -100;
else rawScore = 200 * (latest.value - low) / (high - low) - 100;
const score = inverse ? -rawScore : rawScore;
return Math.max(-100, Math.min(100, score));
}}
function smoothBand(band, radius) {{
if (!band.length || radius <= 0) return band;
@@ -1003,10 +1005,11 @@ function drawChart(svg, years) {{
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 y=v=> T + (max-v)/(max-min)*(H-T-B);
const invertY = svg.dataset.dim === "库存";
const y = invertY ? v => T + (v-min)/(max-min)*(H-T-B) : 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;
const yy=T+i*(H-T-B)/4, val=invertY ? min+i*(max-min)/4 : 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>`;
}}
// x轴刻度
@@ -1095,11 +1098,13 @@ function drawHistoryChart(list) {{
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);
const yMin = -100, yMax = 100;
const y = v => T + (yMax - v) / (yMax - yMin) * (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>`;
for (let i=0;i<=4;i++) {{
const val = yMin + i * (yMax - yMin) / 4, yy = y(val);
const isZero = val === 0;
out += `<line class="${{isZero ? 'axis' : 'grid'}}" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="8" y="${{yy+4}}" font-size="12" fill="${{isZero ? '#374151' : '#667085'}}" font-weight="${{isZero ? 'bold' : 'normal'}}">${{val}}</text>`;
}}
const ticks = 6;
for (let i=0;i<=ticks;i++) {{
@@ -1158,7 +1163,7 @@ function drawHistoryChart(list) {{
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 guideYValue = Math.max(-100, Math.min(100, 100 - ((py - T) / (H - T - B)) * 200));
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";
+41 -6
View File
@@ -172,6 +172,24 @@ def _extract_year_prefix(contract: str) -> int | None:
return None
def _contract_year(contract: str) -> int | None:
"""从合约代码推断完整年份。
上期所/大商所/中金所 4位数字: RB2610 → 2000+26=2026
郑商所 3位数字: MA609 → 2020+6=2026(当前十年2020s
"""
digits = "".join(c for c in contract if c.isdigit())
if len(digits) < 3:
return None
try:
prefix = int(digits[:-2])
if len(digits) == 3: # 郑商所3位数字,前缀1位
return 2020 + prefix
else: # 4位数字,前缀2位
return 2000 + prefix
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)
@@ -254,7 +272,21 @@ def build_historical_spreads(
spread_series = spread_series[spread_series.index >= f"{start_year}-01-01"]
if spread_series.dropna().empty:
continue
# 推断年份:取序列中间日期的年份
# 只保留远月合约到期前约12个月开始的数据
# 远月合约 c2 交割年月 = (Y, far_month),保留 date(Y-1, far_month, 1) 之后的数据
_far_year = _contract_year(c2)
if _far_year is not None:
try:
_far_start = pd.Timestamp(year=_far_year - 1, month=far_month, day=1)
spread_series = spread_series[spread_series.index >= _far_start]
except (ValueError, TypeError):
pass
if spread_series.dropna().empty:
continue
# 推断年份:从近月合约代码提取完整年份
year_val = _contract_year(c1)
if year_val is None:
# 降级方案:使用序列中间日期的年份
mid_idx = len(spread_series) // 2
year_val = int(spread_series.index[mid_idx].year)
hist_series_list.append({
@@ -336,16 +368,19 @@ def _builtin_band_score(value: float, history: pd.Series, day: int, inverse: boo
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)
raw_score = 100.0 if value >= high else -100.0
elif value >= high:
raw_score = 100.0
elif value <= low:
raw_score = -100.0
else:
score = 100.0 if value >= high else (0.0 if value <= low else (value - low) / (high - low) * 100)
raw_score = 200.0 * (value - low) / (high - low) - 100.0
score = -raw_score if inverse else raw_score
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),
"score": round(max(-100.0, min(100.0, score)), 1),
"raw_percentile": round(raw_pct, 1),
"band_low": round(low, 4),
"band_high": round(high, 4),
File diff suppressed because one or more lines are too long
Binary file not shown.