mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
Merge branch 'feat/stock-analysis-levels-refactor': 个股分析价位体系重构
This commit is contained in:
@@ -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": {}}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)[] }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user