diff --git a/backend/app/api/stock_analysis.py b/backend/app/api/stock_analysis.py index 6e31a72..8926fc1 100644 --- a/backend/app/api/stock_analysis.py +++ b/backend/app/api/stock_analysis.py @@ -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": {}} diff --git a/backend/app/indicators/levels.py b/backend/app/indicators/levels.py index 4af1bdd..ad3f204 100644 --- a/backend/app/indicators/levels.py +++ b/backend/app/indicators/levels.py @@ -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), diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py index 7538303..88e4d9d 100644 --- a/backend/app/services/stock_analyzer.py +++ b/backend/app/services/stock_analyzer.py @@ -135,7 +135,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深 - **上方压力位**(逐档列出,标注强度):第一压力、第二压力 - **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑 - 给出**建议买入区间**与**止损位**(基于支撑位) -用数据说话,引用提供的压力/支撑/密集区/枢轴点数值。 +用数据说话,引用提供的压力/支撑(成交密集区)/枢轴点数值。 ### 4. 🏭 基本面与财务面(辅助验证) 简要点评(2-4 句,不展开长篇): diff --git a/frontend/src/components/stock-analysis/AnalysisKChart.tsx b/frontend/src/components/stock-analysis/AnalysisKChart.tsx index 5400afa..4cde2bd 100644 --- a/frontend/src/components/stock-analysis/AnalysisKChart.tsx +++ b/frontend/src/components/stock-analysis/AnalysisKChart.tsx @@ -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({ )} )} + {/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
- {/* 价位概览:把当前开启的点位按"压力 / 支撑"结构化列出 */} + {/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */} {levels && ( 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) }) } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c8359cd..888acfc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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)[] } diff --git a/frontend/src/pages/StockAnalysis.tsx b/frontend/src/pages/StockAnalysis.tsx index c43dbf4..905ed65 100644 --- a/frontend/src/pages/StockAnalysis.tsx +++ b/frontend/src/pages/StockAnalysis.tsx @@ -192,15 +192,28 @@ function StockAnalysisBoard({ symbol }: { symbol: string }) { const levels = (levelsQ.data?.levels ?? {}) as Record + // 涨跌色:最后一根 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 (
-
- - 关键价位分析 - - {rows.length} 个交易日 · 当前价 {levelsQ.data?.close?.toFixed(2) ?? '—'} - +
+
+ + 关键价位分析 +
+
+ {rows.length} 个交易日 + · + 当前价 + + {curClose?.toFixed(2) ?? '—'} + +
@@ -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} />