Merge branch 'feat/stock-analysis-levels-refactor': 个股分析价位体系重构

This commit is contained in:
shy3130
2026-06-26 23:37:42 +08:00
6 changed files with 223 additions and 166 deletions
+11 -7
View File
@@ -3,7 +3,7 @@
路由前缀: /api/stock-analysis
端点:
GET /levels?symbol= 4 类关键价位(图表 markLine 数据源)
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
POST /analyze AI 流式四维分析(NDJSON)
GET /reports 历史报告列表
POST /reports 保存一条报告
@@ -66,11 +66,12 @@ def _build_series(df: pl.DataFrame) -> dict:
close = df["close"]
has_atr = "atr_14" in df.columns
# 布林带
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
if "boll_upper" in df.columns and "boll_lower" in df.columns:
out["boll"] = {
"upper": _to_float_list(df["boll_upper"]),
"lower": _to_float_list(df["boll_lower"]),
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
}
# Keltner 通道三档(需要 ATR)
@@ -107,10 +108,12 @@ def get_levels(
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
):
"""计算 4 类关键价位(压力支撑 / 成交密集区 / 枢轴点 / 前高前低)。
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
返回 {levels: {sr, profile, pivot, extreme}, close, summary}。
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
atr_stop, gap, fib, round}, close, summary, dates, series}
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
"""
if not symbol:
raise HTTPException(400, "symbol 不能为空")
@@ -120,8 +123,9 @@ def get_levels(
start = end - timedelta(days=days * 2)
df = repo.get_daily(symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "profile": [], "pivot": [], "extreme": [],
"keltner": [], "atr_stop": [], "gap": [], "fib": [], "round": []},
return {"levels": {"sr": [], "pivot": [], "extreme": [],
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
"atr_stop": [], "gap": [], "fib": [], "round": []},
"close": None, "summary": "无数据", "symbol": symbol,
"dates": [], "series": {}}
+110 -90
View File
@@ -41,11 +41,13 @@ class PriceLevel:
# 价位分组 → 开关 key。前端按这个 type 显隐。
LEVEL_TYPES = {
"sr": "压力支撑", # 布林带 + swing 高低点
"profile": "成交密集区", # 成交量分布 POC + 密集区
"sr": "压力支撑", # 成交密集区(价量:Volume Profile POC + 高成交密集区)
"pivot": "枢轴点", # 经典 Pivot P/R/S
"extreme": "前高前低", # 60/120/250 日极值
"keltner": "Keltner通道", # 短/中/长三档 MA±n×ATR
"extreme": "前高前低", # 60/250 日极值 + 近期 swing 高低点
"boll": "布林带", # MA20 ± 2σ,标准差波动带(参考性,非真实支撑压力)
"keltner_s": "Keltner短期", # MA20 ± 2×ATR
"keltner_m": "Keltner中期", # MA60 ± 2.5×ATR
"keltner_l": "Keltner长期", # MA120 ± 3×ATR(牛熊趋势边界)
"atr_stop": "ATR止损", # close±nATR 动态止盈止损
"gap": "缺口位", # 未回补跳空缺口
"fib": "斐波那契", # 回撤位 0.236~0.786
@@ -54,43 +56,19 @@ LEVEL_TYPES = {
# ================================================================
# 1. 压力位 / 支撑位 —— 布林带上下轨 + 局部 swing 高低点
# 1. 压力位 / 支撑位 —— 成交量分布 (Volume Profile)
# ================================================================
def _support_resistance(df: pl.DataFrame) -> list[dict]:
"""布林带上下轨(压力/支撑带)
def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
"""成交量分布 (Volume Profile) —— 真正基于价+量的支撑/压力位
本组只放"通道型"压力支撑 —— 即布林带的上下轨,代表近期波动边界。
局部 swing 高低点 / 前高前低 等点位归到 extreme 组,避免与本组重叠。
"""
if df.is_empty() or df.height < 20:
return []
把每个价位层按价格分桶,统计落在该桶的累计成交量,取高成交密集区作为关键
价位带。与 BOLL/Keltner 等"波动通道"不同,成交密集区反映的是真实换手堆积,
是经典意义的支撑/压力。
out: list[dict] = []
last = df.tail(1)
if "boll_upper" in df.columns:
bu = last["boll_upper"][0]
if _ok(bu):
out.append({"value": round(float(bu), 2), "label": "压力位(布林上轨)",
"type": "sr", "side": "resistance", "strength": "medium"})
if "boll_lower" in df.columns:
bl = last["boll_lower"][0]
if _ok(bl):
out.append({"value": round(float(bl), 2), "label": "支撑位(布林下轨)",
"type": "sr", "side": "support", "strength": "medium"})
return out
# ================================================================
# 2. 成交密集区 —— 成交量分布 (Volume Profile)
# ================================================================
def _volume_profile(df: pl.DataFrame, bins: int = 40) -> list[dict]:
"""按价格分桶统计成交量,找 POC(控制点)+ 高成交密集区。
密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带。
密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带:
- POC(控制点):成交量最大的桶,标记为 strong
- 其他高成交区:高于均值,标记为 medium
"""
if df.is_empty() or "volume" not in df.columns or df.height < 20:
return []
@@ -138,7 +116,7 @@ def _volume_profile(df: pl.DataFrame, bins: int = 40) -> list[dict]:
poc_pos = max(range(len(vols)), key=lambda i: vols[i])
poc_mid = bin_mid(bin_ids[poc_pos])
out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)",
"type": "profile", "side": _side(poc_mid, close), "strength": "strong"})
"type": "sr", "side": _side(poc_mid, close), "strength": "strong"})
# 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个
candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos]
@@ -146,12 +124,12 @@ def _volume_profile(df: pl.DataFrame, bins: int = 40) -> list[dict]:
for i, _v in candidates[:2]:
mid = bin_mid(bin_ids[i])
out.append({"value": round(mid, 2), "label": "成交密集区",
"type": "profile", "side": _side(mid, close), "strength": "medium"})
"type": "sr", "side": _side(mid, close), "strength": "medium"})
return out
# ================================================================
# 3. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日
# 2. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日
# ================================================================
def _pivot_points(df: pl.DataFrame) -> list[dict]:
@@ -198,7 +176,7 @@ def _pivot_points(df: pl.DataFrame) -> list[dict]:
# ================================================================
# 4. 前高 / 前低 —— 60 / 120 / 250 日极值
# 3. 前高 / 前低 —— 60 / 120 / 250 日极值
# ================================================================
def _extreme_levels(df: pl.DataFrame) -> list[dict]:
@@ -260,62 +238,101 @@ def _extreme_levels(df: pl.DataFrame) -> list[dict]:
# ================================================================
# 5. Keltner 通道 —— MA ± n × ATR,短/中/长三档
# 4. 波动通道 —— 布林带 + Keltner 三档,各自独立开关
# ================================================================
def _keltner_channels(df: pl.DataFrame) -> list[dict]:
"""三档 Keltner 通道(波动自适应边界)。
def _ma_value(df: pl.DataFrame, ma_col: str | None, window: int) -> float | None:
"""取某档均线值:优先用预计算列,缺失则现场 rolling_mean。"""
last = df.tail(1)
if ma_col and ma_col in df.columns:
v = last[ma_col][0]
return float(v) if _ok(v) else None
if df.height >= window:
v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0]
return float(v) if _ok(v) else None
return None
基于 ATR 的通道:均线 ± n×ATR。ATR 自适应波动,通道宽度随行情自动收缩/扩张,
比布林带(基于标准差)更稳定,实战常用作趋势边界与突破参照。
三档:
- 短期:MA20 ± 2×ATR (近期波动带,约一个月)
- 中期:MA60 ± 2.5×ATR (季度波动带)
- 长期:MA120 ± 3×ATR (半年波动带,牛熊趋势边界)
def _keltner_band(
df: pl.DataFrame, ma_col: str | None, window: int, n: float,
label_short: str, type_key: str,
) -> list[dict]:
"""单档 Keltner 通道:均线 ± n×ATR。
ATR 自适应波动,通道宽度随行情自动收缩/扩张。type_key 决定归入哪一组
(keltner_s / keltner_m / keltner_l),前端各自独立开关。
"""
if df.is_empty() or df.height < 20:
if df.is_empty() or df.height < 20 or "atr_14" not in df.columns:
return []
last = df.tail(1)
close = float(last["close"][0]) if "close" in df.columns else 0
atr = float(last["atr_14"][0])
if not close or not _ok(atr):
return []
ma_val = _ma_value(df, ma_col, window)
if ma_val is None:
return []
upper = ma_val + n * atr
lower = ma_val - n * atr
return [
{"value": round(upper, 2), "label": f"{label_short}通道上轨",
"type": type_key, "side": _side(upper, close), "strength": "medium"},
{"value": round(lower, 2), "label": f"{label_short}通道下轨",
"type": type_key, "side": _side(lower, close), "strength": "medium"},
]
def _boll_channel(df: pl.DataFrame) -> list[dict]:
"""布林带上下轨(MA20 ± 2σ)。
基于标准差的波动带,反映价格相对均线的统计偏离;非真实支撑压力,
仅作波动边界参考。数据直接取预计算列 boll_upper/boll_lower。
"""
if df.is_empty() or "boll_upper" not in df.columns or "boll_lower" not in df.columns:
return []
out: list[dict] = []
last = df.tail(1)
close = float(last["close"][0]) if "close" in df.columns else 0
if not close:
return []
# ATR 列由 compute_indicators 生成(atr_14);缺失则跳过
if "atr_14" not in df.columns:
bu = last["boll_upper"][0]
bl = last["boll_lower"][0]
if not _ok(bu) or not _ok(bl):
return []
atr = float(last["atr_14"][0])
if not _ok(atr):
return []
def _band(ma_col: str | None, n: int, label_short: str, window: int) -> None:
"""单档通道:优先取预计算列,缺失则现场 rolling_mean。"""
ma_val: float | None = None
if ma_col and ma_col in df.columns:
v = last[ma_col][0]
ma_val = float(v) if _ok(v) else None
elif df.height >= window:
# ma120 等未预计算列:现场算
v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0]
ma_val = float(v) if _ok(v) else None
if ma_val is None:
return
upper = ma_val + n * atr
lower = ma_val - n * atr
out.append({"value": round(upper, 2), "label": f"{label_short}通道上轨",
"type": "keltner", "side": _side(upper, close), "strength": "medium"})
out.append({"value": round(lower, 2), "label": f"{label_short}通道下轨",
"type": "keltner", "side": _side(lower, close), "strength": "medium"})
_band("ma20", 2, "短期", 20)
_band("ma60", 2.5, "中期", 60)
_band(None, 3, "长期", 120) # ma120 未预计算,现场 rolling_mean
bu, bl = float(bu), float(bl)
out = [
{"value": round(bu, 2), "label": "布林上轨",
"type": "boll", "side": _side(bu, close), "strength": "medium"},
{"value": round(bl, 2), "label": "布林下轨",
"type": "boll", "side": _side(bl, close), "strength": "medium"},
]
# 布林中轨 = MA20(多空平衡线,价格在其上下分强弱);数据层已预计算 ma20
if "ma20" in df.columns:
mid = last["ma20"][0]
if _ok(mid):
mid = float(mid)
out.append({"value": round(mid, 2), "label": "布林中轨",
"type": "boll", "side": _side(mid, close), "strength": "medium"})
return out
def _keltner_short(df: pl.DataFrame) -> list[dict]:
"""Keltner 短期:MA20 ± 2×ATR(近期波动带,约一个月)。"""
return _keltner_band(df, "ma20", 20, 2.0, "短期", "keltner_s")
def _keltner_mid(df: pl.DataFrame) -> list[dict]:
"""Keltner 中期:MA60 ± 2.5×ATR(季度波动带)。"""
return _keltner_band(df, "ma60", 60, 2.5, "中期", "keltner_m")
def _keltner_long(df: pl.DataFrame) -> list[dict]:
"""Keltner 长期:MA120 ± 3×ATR(半年波动带,牛熊趋势边界)。"""
return _keltner_band(df, None, 120, 3.0, "长期", "keltner_l")
# ================================================================
# 6. ATR 止损位 —— close ± n × ATR,动态止盈止损
# 5. ATR 止损位 —— close ± n × ATR,动态止盈止损
# ================================================================
def _atr_stops(df: pl.DataFrame) -> list[dict]:
@@ -347,7 +364,7 @@ def _atr_stops(df: pl.DataFrame) -> list[dict]:
# ================================================================
# 7. 缺口位 (Gap) —— 未回补的跳空缺口
# 6. 缺口位 (Gap) —— 未回补的跳空缺口
# ================================================================
def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
@@ -400,7 +417,7 @@ def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
# ================================================================
# 8. 斐波那契回撤 —— 基于近期波段的回撤位
# 7. 斐波那契回撤 —— 基于近期波段的回撤位
# ================================================================
def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]:
@@ -442,7 +459,7 @@ def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]:
# ================================================================
# 9. 整数关口 —— 心理支撑/阻力位
# 8. 整数关口 —— 心理支撑/阻力位
# ================================================================
def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> list[dict]:
@@ -497,10 +514,11 @@ def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> l
return out
def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]:
"""计算 9 类价位点,返回 {分组key: [点位...]}。
"""计算 11 类价位点,返回 {分组key: [点位...]}。
分组 key 与 LEVEL_TYPES 一致(sr / profile / pivot / extreme /
keltner / atr_stop / gap / fib / round),前端按 key 渲染开关按钮,逐组显隐。
分组 key 与 LEVEL_TYPES 一致(sr / pivot / extreme / boll /
keltner_s / keltner_m / keltner_l / atr_stop / gap / fib / round),
前端按 key 渲染开关按钮,逐组显隐。
"""
if df.is_empty():
return {k: [] for k in LEVEL_TYPES}
@@ -508,10 +526,12 @@ def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]:
try:
return {
"sr": _support_resistance(df),
"profile": _volume_profile(df),
"pivot": _pivot_points(df),
"extreme": _extreme_levels(df),
"keltner": _keltner_channels(df),
"boll": _boll_channel(df),
"keltner_s": _keltner_short(df),
"keltner_m": _keltner_mid(df),
"keltner_l": _keltner_long(df),
"atr_stop": _atr_stops(df),
"gap": _gap_levels(df),
"fib": _fibonacci_levels(df),
+1 -1
View File
@@ -135,7 +135,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深
- **上方压力位**(逐档列出,标注强度):第一压力、第二压力
- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑
- 给出**建议买入区间**与**止损位**(基于支撑位)
用数据说话,引用提供的压力/支撑/密集区/枢轴点数值。
用数据说话,引用提供的压力/支撑(成交密集区)/枢轴点数值。
### 4. 🏭 基本面与财务面(辅助验证)
简要点评(2-4 句,不展开长篇):
@@ -29,7 +29,7 @@ const THEME = {
}
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
@@ -43,17 +43,37 @@ export interface PriceLevel {
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙
{ key: 'profile', label: '成交密集', color: '#3B82F6' }, // 蓝
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙(成交密集区,价量驱动)
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
{ key: 'keltner', label: 'Keltner', color: '#06B6D4' }, //
{ key: 'boll', label: '布林带', color: '#F97316' }, // 橙(MA20±2σ 曲线)
{ key: 'keltner_s',label: 'Keltner短期', color: '#06B6D4' }, // 青(MA20±2ATR 曲线)
{ key: 'keltner_m',label: 'Keltner中期', color: '#22D3EE' }, // 浅青(MA60±2.5ATR 曲线)
{ key: 'keltner_l',label: 'Keltner长期', color: '#67E8F9' }, // 更浅青(MA120±3ATR 曲线)
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
]
// 通道曲线元数据(单一数据源):供 buildOption 画线 + 右侧面板取最新值共用。
// alignedKey: alignedSeries 中的 key(由 series.boll/keltner/atr 对齐而来)
// group: 属于哪个价位开关组(开关该组即开关这条曲线)
// endLabel: 右侧端点标签(显示最新值的文字)
const CURVE_DEFS: { alignedKey: string; group: LevelType; endLabel: string; color: string; dashed?: boolean }[] = [
{ alignedKey: 'boll_upper', group: 'boll', endLabel: '布林上轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_lower', group: 'boll', endLabel: '布林下轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_mid', group: 'boll', endLabel: '布林中轨', color: '#FB923C', dashed: false },
{ alignedKey: 'keltner_s_upper',group: 'keltner_s', endLabel: 'Keltner短上', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_s_lower',group: 'keltner_s', endLabel: 'Keltner短下', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_m_upper',group: 'keltner_m', endLabel: 'Keltner中上', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_m_lower',group: 'keltner_m', endLabel: 'Keltner中下', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_l_upper',group: 'keltner_l', endLabel: 'Keltner长上', color: '#67E8F9', dashed: true },
{ alignedKey: 'keltner_l_lower',group: 'keltner_l', endLabel: 'Keltner长下', color: '#67E8F9', dashed: true },
{ alignedKey: 'atr_stop', group: 'atr_stop', endLabel: 'ATR止损', color: '#EF4444', dashed: true },
{ alignedKey: 'atr_tp', group: 'atr_stop', endLabel: 'ATR止盈', color: '#F87171', dashed: true },
]
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
export interface ChartMarker {
date: string
@@ -94,7 +114,7 @@ export function AnalysisKChart({
levels,
series,
seriesDates,
defaultLevelTypes = ['sr', 'pivot', 'keltner'],
defaultLevelTypes = ['sr', 'pivot', 'keltner_s'],
markers,
ranges,
onDateClick,
@@ -136,6 +156,7 @@ export function AnalysisKChart({
if (series.boll) {
alignedSeries['boll_upper'] = align(series.boll.upper)
alignedSeries['boll_lower'] = align(series.boll.lower)
if (series.boll.mid) alignedSeries['boll_mid'] = align(series.boll.mid)
}
if (series.keltner_s) {
alignedSeries['keltner_s_upper'] = align(series.keltner_s.upper)
@@ -174,23 +195,6 @@ export function AnalysisKChart({
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
const sliderBottom = PAD_BOTTOM
// 主图 markLine(关键价位)
const markLineData: any[] = priceLines.map(p => ({
yAxis: p.value,
lineStyle: { color: p.color, type: 'dashed', width: 1, opacity: 0.85 },
label: {
show: true,
formatter: `${p.label} ${p.value.toFixed(2)}`,
position: 'insideEndTop',
color: p.color,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.72)',
padding: [1, 5],
borderRadius: 3,
},
}))
// 预留:markPoint(新闻标记)
const markPointData: any[] = (markers ?? [])
.filter(m => dateIndex.has(m.date))
@@ -217,7 +221,6 @@ export function AnalysisKChart({
color: THEME.bull, color0: THEME.bear,
borderColor: THEME.bull, borderColor0: THEME.bear,
},
markLine: markLineData.length ? { silent: true, symbol: 'none', animation: false, data: markLineData } : undefined,
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
},
@@ -227,44 +230,61 @@ export function AnalysisKChart({
},
]
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 画成跟随时间漂移的曲线
// 复刻 EChartsCandlestick 的 maLine/bollLine 模式:type=line, symbol=none, smooth
const mkCurve = (key: string, label: string, color: string, dashed = true) => {
const data = alignedSeries[key]
if (!data || !data.some(v => v != null)) return
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
for (const p of priceLines) {
series.push({
name: label, type: 'line', data: data.map(v => v ?? '-'),
smooth: true, symbol: 'none', silent: true, animation: false,
lineStyle: { width: 1, color, type: dashed ? 'dashed' : 'solid', opacity: 0.8 },
itemStyle: { color },
name: p.label, type: 'line', silent: true, animation: false,
symbol: 'none',
data: dates.map(() => p.value),
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
itemStyle: { color: p.color },
endLabel: {
show: true,
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
},
})
}
// sr 组开启 → 布林带曲线(替代水平线)
if (activeTypes.has('sr')) {
mkCurve('boll_upper', '布林上轨', '#F97316')
mkCurve('boll_lower', '布林下轨', '#F97316')
}
// keltner 组开启 → 三档通道曲线
if (activeTypes.has('keltner')) {
mkCurve('keltner_s_upper', '短期通道上', '#06B6D4')
mkCurve('keltner_s_lower', '短期通道下', '#06B6D4')
mkCurve('keltner_m_upper', '中期通道上', '#22D3EE')
mkCurve('keltner_m_lower', '中期通道下', '#22D3EE')
mkCurve('keltner_l_upper', '长期通道上', '#67E8F9')
mkCurve('keltner_l_lower', '长期通道下', '#67E8F9')
}
// atr_stop 组开启 → 止损/止盈曲线
if (activeTypes.has('atr_stop')) {
mkCurve('atr_stop', 'ATR 止损', '#EF4444')
mkCurve('atr_tp', 'ATR 止盈', '#F87171')
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 跟随行情漂移的曲线
// 单一数据源 CURVE_DEFS 驱动:每条曲线带 endLabel(右侧端点标签),显示最新数值
for (const def of CURVE_DEFS) {
if (!activeTypes.has(def.group)) continue
const data = alignedSeries[def.alignedKey]
if (!data || !data.some(v => v != null)) continue
// 取最后一个有效值作为右侧端点显示文字
let lastVal: number | null = null
for (let i = data.length - 1; i >= 0; i--) {
if (data[i] != null) { lastVal = data[i]; break }
}
series.push({
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
smooth: true, symbol: 'none', silent: true, animation: false,
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
itemStyle: { color: def.color },
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
endLabel: lastVal != null ? {
show: true,
formatter: () => `${lastVal!.toFixed(2)}`,
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
} : undefined,
})
}
return {
animation: false,
backgroundColor: 'transparent',
// grid.right 留出足够宽度给价位标签文字区:蜡烛只占左侧主区域,
// 价位线右端的标签文字显示在这条预留带里,不压在蜡烛上。
// 预留 ~144px:最长标签(如「成交密集区(POC) 12.34」)约 13 字符,fontSize 9 等宽。
grid: [
{ left: 56, right: 64, top: 16, height: mainH },
{ left: 56, right: 64, top: volTop, height: volH },
{ left: 56, right: 144, top: 16, height: mainH },
{ left: 56, right: 144, top: volTop, height: volH },
],
xAxis: [
{
@@ -390,9 +410,10 @@ export function AnalysisKChart({
)}
</div>
)}
{/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
<div ref={chartRef} style={{ width: '100%', height }} />
{/* 价位概览:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{levels && (
<LevelOverview
levels={levels}
@@ -405,7 +426,7 @@ export function AnalysisKChart({
)
}
// ===== 价位概览面板(结构化文本展示) =====
// ===== 价位统计面板(图表下方,结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
}: {
@@ -501,11 +522,10 @@ function collectPriceLines(
for (const p of levels[g.key] ?? []) {
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
// 带状指标改由曲线渲染,跳过水平线:
// - keltner / atr_stop 整组走曲线
// - sr 组的布林带(label 含"布林")走曲线
if (p.type === 'keltner' || p.type === 'atr_stop') continue
if (p.type === 'sr' && p.label.includes('布林')) continue
// 波动通道类(boll / keltner三档 / atr_stop)整组走曲线渲染,不画水平线;
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
}
}
+2 -2
View File
@@ -117,7 +117,7 @@ export interface AiFinancialReport {
}
// ===== 个股分析 =====
export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
@@ -131,7 +131,7 @@ export interface PriceLevel {
/** 带状曲线指标(布林带/Keltner/ATR)的每日时间序列,与 dates 对齐。 */
export interface LevelSeries {
boll?: { upper: (number | null)[]; lower: (number | null)[] }
boll?: { upper: (number | null)[]; lower: (number | null)[]; mid?: (number | null)[] }
keltner_s?: { upper: (number | null)[]; lower: (number | null)[] }
keltner_m?: { upper: (number | null)[]; lower: (number | null)[] }
keltner_l?: { upper: (number | null)[]; lower: (number | null)[] }
+20 -7
View File
@@ -192,15 +192,28 @@ function StockAnalysisBoard({ symbol }: { symbol: string }) {
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
// 涨跌色:最后一根 K 线收 vs 前一根收(无前日则按开收判断)
const last = rows[rows.length - 1]
const prev = rows[rows.length - 2]
const curClose = levelsQ.data?.close
const isUp = prev ? (last.close >= prev.close) : (last.close >= last.open)
return (
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<div className="flex items-center gap-2">
<LineChart className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[10px] text-muted">
{rows.length} · {levelsQ.data?.close?.toFixed(2) ?? '—'}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<LineChart className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="flex items-baseline gap-2 shrink-0">
<span className="text-[10px] text-muted">{rows.length} </span>
<span className="text-[10px] text-muted/60">·</span>
<span className="text-[10px] text-muted"></span>
<span className={`text-base font-mono font-bold ${isUp ? 'text-bull' : 'text-bear'}`}>
{curClose?.toFixed(2) ?? '—'}
</span>
</div>
</div>
</div>
<div className="p-3">
@@ -209,7 +222,7 @@ function StockAnalysisBoard({ symbol }: { symbol: string }) {
levels={levels}
series={levelsQ.data?.series}
seriesDates={levelsQ.data?.dates}
defaultLevelTypes={['sr', 'pivot', 'keltner']}
defaultLevelTypes={['sr', 'pivot', 'keltner_s']}
height={480}
/>
</div>