Compare commits

...

28 Commits

Author SHA1 Message Date
fish
947e227d5f 支撑位和阻力位列内容居中展示 2026-05-11 22:42:28 +08:00
fish
5dad7a6a02 表格支撑位和阻力位拆为四列:支撑位一/二、阻力位一/二 2026-05-11 22:21:21 +08:00
fish
b91bffdb4c 支撑位和阻力位改为逐个标注:表格用S1/R1标签,抽屉分开展示每层价位 2026-05-11 22:07:36 +08:00
fish
e756c3f300 列表页移除分析逻辑和风险提示列,改为查看按钮引导至抽屉明细 2026-05-11 21:44:46 +08:00
fish
bd48887b88 日内方向分析详情改用抽屉面板展示,点击表格行查看完整逻辑与风险提示 2026-05-11 21:39:21 +08:00
fish
5c30bfa472 日内方向分析按钮自动先执行批量打分再调用AI分析 2026-05-11 21:23:53 +08:00
fish
f5615d9580 新增日内方向分析功能:基于三层打分数据由 AI 批量生成下一个交易日方向判断 2026-05-11 21:18:29 +08:00
fish
6ab310cfb3 AI报告第4点改为支撑与阻力专节,喂入30日K线数据,Go构建加goproxy镜像 2026-05-10 18:03:10 +08:00
fish
c47735f3b6 AI分析Markdown样式优化:紧凑段落间距、标题上下留白、npm淘宝镜像 2026-05-10 17:45:58 +08:00
fish
1d1a6d6cdf AI分析Markdown渲染间距优化,去除pre-wrap双重间距;Dockerfile npm换国内镜像
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:32:49 +08:00
fish
04636c53d8 AI分析内容支持Markdown渲染,标题、列表、加粗正常显示 2026-05-10 17:21:52 +08:00
fish
4cdc542291 修复AI分析SSE流断开:中间件透传Flusher、DB Key生效、错误提示不覆盖 2026-05-10 17:11:51 +08:00
fish
1094e82e88 品种打分结果卡增加AI分析入口,打完分可直接问AI 2026-05-10 16:31:26 +08:00
fish
99c2a5bcbf AI分析功能:LLM Key 改为数据库管理,支持管理员后台配置 2026-05-10 16:21:15 +08:00
fish
ad9edf7ad4 K线图参数标题汉化:Y轴标签、Tooltip OHLC 字段改为中文 2026-05-10 15:59:36 +08:00
fish
819b327cdb 打分算法全面改进:修复方向性bug,引入自适应权重与分数动量 2026-05-10 15:52:03 +08:00
fish
f2e4bf7041 打分请求超时时间延长至 60 秒
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:19:37 +08:00
fish
e6351750cf 打分列表改为品种打分,移除同步数据功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:19:00 +08:00
fish
465feaa833 修复 NaN 值导致 JSON 序列化失败、K 线数据无法展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:02:39 +08:00
fish
f7b60659ab 修复 Tushare 返回的 ts_code 缺少交易所后缀导致查不到数据
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:56:19 +08:00
fish
fef806f796 合约全景刷新时自动拉取打分,并同步回写完整合约代码
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:49:33 +08:00
fish
512d43121c 合约全景功能独立为单独菜单,与原 K 线页面分离
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:44:46 +08:00
fish
01309dd8ff 支持短合约代码自动补全交易所后缀
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:31:45 +08:00
fish
ee3acd1c4d 新增合约全历史拉取与 K 线打分叠加功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:29:00 +08:00
fish
9d2997a3cb 手动打分页面移除主力合约展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:58:33 +08:00
fish
cdf793608d 区间打分根据用户选择日期自动判断主力合约,放宽前端日期限制支持历史区间
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:48:20 +08:00
fish
c54ba5a470 修复区间打分 422 错误:Go 后端新增 runRangeRequest 结构体正确透传 start_date/end_date
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:41:51 +08:00
fish
01edda923a 新增自定义时间段批量打分功能:支持设置日期区间,对区间内每天自动拉取数据并打分
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:37:20 +08:00
35 changed files with 3065 additions and 291 deletions

View File

@@ -1,4 +1,5 @@
tushare>=1.4.0 tushare>=1.4.0
numpy>=2.0.0
pandas>=2.2.0 pandas>=2.2.0
fastapi>=0.115.0 fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0

View File

@@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from datetime import date from datetime import date, datetime, timedelta
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
@@ -16,6 +16,16 @@ class RunRequest(BaseModel):
trade_date: Optional[str] = None trade_date: Optional[str] = None
class RunRangeRequest(BaseModel):
symbol: str = "FG"
start_date: str
end_date: str
class RunFullRequest(BaseModel):
ts_code: str
class RunResponse(BaseModel): class RunResponse(BaseModel):
ts_code: str ts_code: str
trade_date: str trade_date: str
@@ -27,6 +37,9 @@ class RunResponse(BaseModel):
long_term: float long_term: float
composite: float composite: float
signal: str signal: str
vol_penalty: float = 1.0
composite_delta: Optional[float] = None
composite_delta_5d: Optional[float] = None
@app.on_event("startup") @app.on_event("startup")
@@ -41,8 +54,11 @@ def health():
@app.post("/api/v1/run", response_model=RunResponse) @app.post("/api/v1/run", response_model=RunResponse)
def run_pipeline(req: RunRequest): def run_pipeline(req: RunRequest):
ts_code = req.ts_code or contracts.active_contract(req.symbol) ref_date = datetime.strptime(req.trade_date, "%Y%m%d").date() if req.trade_date else None
if not req.ts_code: ts_code = req.ts_code or contracts.active_contract(req.symbol, ref_date)
if req.ts_code:
ts_code = contracts.normalize_ts_code(ts_code)
else:
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
@@ -61,6 +77,9 @@ def run_pipeline(req: RunRequest):
long_term=result.long_term, long_term=result.long_term,
composite=result.composite, composite=result.composite,
signal=result.signal, signal=result.signal,
vol_penalty=result.vol_penalty,
composite_delta=result.composite_delta,
composite_delta_5d=result.composite_delta_5d,
) )
@@ -88,6 +107,74 @@ def run_batch():
return {"results": results, "errors": errors} return {"results": results, "errors": errors}
@app.post("/api/v1/run/range")
def run_range(req: RunRangeRequest):
"""对指定日期区间内的每一天分别打分。"""
start_dt = datetime.strptime(req.start_date, "%Y%m%d").date()
ts_code = contracts.active_contract(req.symbol, start_dt)
# 为确保区间开始日有足够前置数据,拉取时 start_date 前推 60 天
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=req.end_date)
storage.save_candles(df)
results, warnings = scorer.score_range(df, req.start_date, req.end_date)
for r in results:
storage.save_score(r)
return {
"ts_code": ts_code,
"start_date": req.start_date,
"end_date": req.end_date,
"scored": len(results),
"skipped": len(warnings),
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.post("/api/v1/run/full")
def run_full(req: RunFullRequest):
"""拉取指定合约全部历史数据,保存 candles对所有可打分日期逐日打分并保存。"""
ts_code = contracts.normalize_ts_code(req.ts_code)
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
results, warnings, total_days, scored_count = scorer.score_all(df)
for r in results:
storage.save_score(r)
skipped_count = total_days - scored_count
return {
"ts_code": ts_code,
"total_days": total_days,
"scored_count": scored_count,
"skipped_count": skipped_count,
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.get("/api/v1/scores") @app.get("/api/v1/scores")
def list_scores( def list_scores(
ts_code: Optional[str] = Query(None), ts_code: Optional[str] = Query(None),

View File

@@ -66,6 +66,18 @@ ROLLOVER_RULES: dict[str, dict] = {
} }
def normalize_ts_code(ts_code: str) -> str:
"""把用户输入的短代码(如 FG2509)补全为带交易所后缀的完整代码(如 FG2509.ZCE)。"""
if "." in ts_code:
return ts_code
# 提取品种代码: 前两个字母
symbol = ts_code[:2]
if symbol not in ROLLOVER_RULES:
raise ValueError(f"未配置 {symbol} 的品种规则,无法自动补全交易所后缀")
exchange = ROLLOVER_RULES[symbol]["exchange"]
return f"{ts_code}.{exchange}"
def active_contract(symbol: str, today: Optional[date] = None) -> str: def active_contract(symbol: str, today: Optional[date] = None) -> str:
"""按主力轮换规则,返回当日 ts_code(含交易所后缀)。""" """按主力轮换规则,返回当日 ts_code(含交易所后缀)。"""
if symbol not in ROLLOVER_RULES: if symbol not in ROLLOVER_RULES:

View File

@@ -11,10 +11,19 @@ def _init_api():
return ts.pro_api() return ts.pro_api()
def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame: def fetch_contract(
ts_code: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame:
"""拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。""" """拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。"""
pro = _init_api() pro = _init_api()
df = pro.fut_daily(ts_code=ts_code) kwargs: dict = {"ts_code": ts_code}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
df = pro.fut_daily(**kwargs)
if df.empty: if df.empty:
raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足") raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足")
@@ -25,6 +34,10 @@ def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame:
] ]
df = df[[c for c in cols if c in df.columns]].copy() df = df[[c for c in cols if c in df.columns]].copy()
# 确保 ts_code 列与查询时传入的完整代码一致Tushare 返回的可能不带交易所后缀)
if "ts_code" in df.columns:
df["ts_code"] = ts_code
numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"] numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"]
for c in numeric: for c in numeric:
if c in df.columns: if c in df.columns:

View File

@@ -74,13 +74,77 @@ def run(ts_code: str, trade_date: Optional[str] = None) -> int:
print(f"\n[波动率调整]") print(f"\n[波动率调整]")
print(f" 日波动率(30d std): {vd['daily_vol_pct']*100:.2f}%") print(f" 日波动率(30d std): {vd['daily_vol_pct']*100:.2f}%")
print(f" ATR%: {vd['atr_pct']*100:.2f}%") print(f" ATR%: {vd['atr_pct']*100:.2f}%")
vol_risk = vd.get("vol_risk", vd["daily_vol_pct"])
print(f" 综合波动风险: {vol_risk*100:.2f}%")
print(f" 惩罚系数: {vd['vol_penalty']:.3f}") print(f" 惩罚系数: {vd['vol_penalty']:.3f}")
aw = result.detail.adaptive_weights
if aw:
print(f"\n[自适应权重]")
print(f" 趋势强度: {aw['trend_strength']:.2f}")
print(f" 短期权重: {aw['w_short']:.2%} (基准 40%)")
print(f" 中期权重: {aw['w_medium']:.2%} (基准 35%)")
print(f" 长期权重: {aw['w_long']:.2%} (基准 25%)")
if result.composite_delta is not None:
print(f"\n[分数动量]")
print(f" 日变化 (Δ1d): {result.composite_delta:+.1f}")
if result.composite_delta_5d is not None:
print(f" 周变化 (Δ5d): {result.composite_delta_5d:+.1f}")
print(f"\n[OK] 数据已持久化到 PostgreSQL") print(f"\n[OK] 数据已持久化到 PostgreSQL")
return 0 return 0
def run_range(ts_code: str, start_date: str, end_date: str) -> int:
from datetime import datetime, timedelta
storage.init_db()
print(f"[1/4] 拉取 {ts_code} 数据 ({start_date} ~ {end_date})...")
start_dt = datetime.strptime(start_date, "%Y%m%d")
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=end_date)
print(f" 返回 {len(df)}")
print(f"[2/4] 写入/更新 PostgreSQL...")
storage.save_candles(df)
print(f"[3/4] 批量计算打分...")
results, warnings = scorer.score_range(df, start_date, end_date)
print(f"[4/4] 保存打分结果...")
for r in results:
storage.save_score(r)
print("\n" + "=" * 65)
print(f"合约: {ts_code}")
print(f"区间: {start_date} ~ {end_date}")
print(f"成功打分: {len(results)}")
if warnings:
print(f"跳过: {len(warnings)}")
for w in warnings[:5]:
print(f" - {w}")
if len(warnings) > 5:
print(f" ... 还有 {len(warnings) - 5}")
print("=" * 65)
quadrant_names = {
"accumulation": "增仓上涨", "distribution": "增仓下跌",
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
}
print(f"\n{'日期':<12} {'收盘':>10} {'综合':>8} {'Δ1d':>6} {'Δ5d':>6} {'信号':<20}")
print("-" * 70)
for r in results:
d1 = f"{r.composite_delta:+.1f}" if r.composite_delta is not None else " -"
d5 = f"{r.composite_delta_5d:+.1f}" if r.composite_delta_5d is not None else " -"
print(f"{r.trade_date:<12} {r.close:>10.2f} {r.composite:>8.1f} {d1:>6} {d5:>6} {r.signal:<20}")
print(f"\n[OK] {len(results)} 条打分已持久化到 PostgreSQL")
return 0
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="期货合约三层打分模型") parser = argparse.ArgumentParser(description="期货合约三层打分模型")
parser.add_argument( parser.add_argument(
@@ -97,11 +161,26 @@ def main() -> int:
"--date", "--date",
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分", help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
) )
parser.add_argument(
"--start-date",
dest="start_date",
help="区间打分开始日期,格式 YYYYMMDD(与 --end-date 同时使用时生效)",
)
parser.add_argument(
"--end-date",
dest="end_date",
help="区间打分结束日期,格式 YYYYMMDD(与 --start-date 同时使用时生效)",
)
args = parser.parse_args() args = parser.parse_args()
ts_code = args.ts_code or contracts.active_contract(args.symbol) ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.ts_code: if not args.ts_code:
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
if args.start_date and args.end_date:
print(f"[RANGE] 区间打分: {args.start_date} ~ {args.end_date}")
return run_range(ts_code, args.start_date, args.end_date)
if args.date: if args.date:
print(f"[DATE] 指定打分日期: {args.date}") print(f"[DATE] 指定打分日期: {args.date}")
return run(ts_code, args.date) return run(ts_code, args.date)

View File

@@ -23,6 +23,7 @@ class ScoreDetail:
medium_detail: dict = field(default_factory=dict) medium_detail: dict = field(default_factory=dict)
long_detail: dict = field(default_factory=dict) long_detail: dict = field(default_factory=dict)
volatility: dict = field(default_factory=dict) volatility: dict = field(default_factory=dict)
adaptive_weights: dict = field(default_factory=dict)
@dataclass @dataclass
@@ -38,3 +39,6 @@ class ScoreResult:
composite: float composite: float
signal: str signal: str
detail: ScoreDetail detail: ScoreDetail
vol_penalty: float = 1.0
composite_delta: Optional[float] = None # 与前一日综合分差值
composite_delta_5d: Optional[float] = None # 与5日前综合分差值

View File

@@ -1,12 +1,18 @@
import math
from typing import Optional from typing import Optional
import numpy as np
import pandas as pd import pandas as pd
from .models import ScoreDetail, ScoreResult from .models import ScoreDetail, ScoreResult
# ---------------------------------------------------------------------------
# 短期动力 — 单日打分
# ---------------------------------------------------------------------------
def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict: def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict:
"""单日短期动力打分(连续值 + 幅度因子 + 量能确认)。""" """单日短期动力打分(连续值 + 方向性幅度加成 + 量能确认)。"""
oi = float(row["oi"]) oi = float(row["oi"])
oi_chg = float(row["oi_chg"]) oi_chg = float(row["oi_chg"])
close = float(row["close"]) close = float(row["close"])
@@ -18,37 +24,42 @@ def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict:
price_up = close >= pre_close price_up = close >= pre_close
oi_increasing = oi_chg > 0 oi_increasing = oi_chg > 0
# 象限基础分 # ── 象限基础分 ──
if abs(oi_chg_pct) < 0.01: if abs(oi_chg_pct) < 0.01:
base = 60.0 if price_up else 40.0 base = 60.0 if price_up else 40.0
quadrant = "flat" quadrant = "flat"
elif oi_increasing and price_up: elif oi_increasing and price_up:
base = 75.0 base = 75.0
quadrant = "accumulation" quadrant = "accumulation" # 增仓上涨
elif oi_increasing and not price_up: elif oi_increasing and not price_up:
base = 25.0 base = 25.0
quadrant = "distribution" quadrant = "distribution" # 增仓下跌
elif not oi_increasing and price_up: elif not oi_increasing and price_up:
base = 65.0 base = 65.0
quadrant = "covering" quadrant = "covering" # 减仓上涨
else: else:
base = 20.0 base = 20.0
quadrant = "liquidation" quadrant = "liquidation" # 减仓下跌
# 幅度加成OI 变化率封顶 5%,价格涨跌幅封顶 3% # ── 幅度加成(方向性)──
# OI 变化率封顶 5%,价格涨跌幅封顶 3%
oi_mag = min(1.0, abs(oi_chg_pct) / 0.05) oi_mag = min(1.0, abs(oi_chg_pct) / 0.05)
price_mag = min(1.0, abs(price_chg_pct) / 0.03) price_mag = min(1.0, abs(price_chg_pct) / 0.03)
if quadrant in ("accumulation", "liquidation"): if quadrant == "accumulation":
boost = (oi_mag + price_mag) / 2.0 * 20.0 boost = (oi_mag + price_mag) / 2.0 * 20.0 # 看多,加成推高分数
elif quadrant == "liquidation":
boost = -(oi_mag + price_mag) / 2.0 * 20.0 # 看空,扣分强化信号
elif quadrant == "flat": elif quadrant == "flat":
boost = price_mag * 10.0 boost = price_mag * 10.0 if price_up else -(price_mag * 10.0)
else: elif quadrant == "distribution":
boost = 0.0 boost = -price_mag * 10.0 # 增仓下跌:价格幅度扣分
else: # covering
boost = price_mag * 10.0 # 减仓上涨:价格幅度加分
# 量能确认 # ── 量能确认ratio=1.0 时为中性因子 1.0)──
vol_ratio = vol / avg_vol_7d if avg_vol_7d > 0 else 1.0 vol_ratio = vol / avg_vol_7d if avg_vol_7d > 0 else 1.0
vol_factor = 0.9 + 0.2 * min(vol_ratio, 1.5) vol_factor = 0.8 + 0.2 * min(vol_ratio, 2.0) # 范围 [0.8, 1.2],中性 = 1.0
score = max(0.0, min(100.0, (base + boost) * vol_factor)) score = max(0.0, min(100.0, (base + boost) * vol_factor))
@@ -67,7 +78,12 @@ def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict:
} }
# ---------------------------------------------------------------------------
# 短期动力 — 7 日窗口,指数加权平均
# ---------------------------------------------------------------------------
def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]: def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]:
"""近 window 日逐日打分,指数加权平均(越近权重越高)。"""
recent = df.iloc[-window:].copy() recent = df.iloc[-window:].copy()
avg_vol_7d = float(recent["vol"].mean()) if "vol" in recent.columns else 0.0 avg_vol_7d = float(recent["vol"].mean()) if "vol" in recent.columns else 0.0
@@ -78,8 +94,17 @@ def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]:
scores.append(detail["score"]) scores.append(detail["score"])
details.append(detail) details.append(detail)
return sum(scores) / len(scores), details # 指数加权:权重从 exp(0)=1 递增到 exp(1)≈2.718
weights = np.exp(np.linspace(0, 1, window))
weights = weights / weights.sum()
weighted_avg = float(np.average(scores, weights=weights))
return weighted_avg, details
# ---------------------------------------------------------------------------
# 中期趋势 — 15 日窗口,四象限资金意愿
# ---------------------------------------------------------------------------
def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]: def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
if len(df) < window + 1: if len(df) < window + 1:
@@ -89,46 +114,65 @@ def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
close_now = float(df.iloc[-1]["close"]) close_now = float(df.iloc[-1]["close"])
close_before = float(df.iloc[-window - 1]["close"]) close_before = float(df.iloc[-window - 1]["close"])
# ── 价格信号 ──
price_return = (close_now - close_before) / close_before if close_before != 0 else 0 price_return = (close_now - close_before) / close_before if close_before != 0 else 0
price_score = max(0.0, min(100.0, 50.0 + price_return * 500)) price_score = max(0.0, min(100.0, 50.0 + price_return * 500))
long_up = 0 # ── 资金意愿(四象限全计入)──
long_down = 0 accumulation = 0 # 增仓上涨
for _, row in recent.iterrows(): distribution = 0 # 增仓下跌
if row["oi_chg"] > 0: covering = 0 # 减仓上涨
if row["close"] >= row["pre_close"]: liquidation = 0 # 减仓下跌
long_up += 1
else: for _, row in recent.iterrows():
long_down += 1 oi_inc = row["oi_chg"] > 0
price_up = row["close"] >= row["pre_close"]
if oi_inc and price_up:
accumulation += 1
elif oi_inc and not price_up:
distribution += 1
elif not oi_inc and price_up:
covering += 1
else:
liquidation += 1
# 增仓驱动权重 1.0,减仓驱动权重 0.5(持续性较差)
fund_score = 50.0 + (
accumulation * 1.0 - distribution * 1.0
+ covering * 0.5 - liquidation * 0.5
) / window * 50.0
fund_score = max(0.0, min(100.0, fund_score))
fund_score = 50.0 + (long_up - long_down) / window * 50.0
score = price_score * 0.6 + fund_score * 0.4 score = price_score * 0.6 + fund_score * 0.4
detail = { detail = {
"price_return_pct": round(price_return * 100, 2), "price_return_pct": round(price_return * 100, 2),
"price_signal": round(price_score, 1), "price_signal": round(price_score, 1),
"long_up_days": long_up, "accumulation_days": accumulation,
"long_down_days": long_down, "distribution_days": distribution,
"covering_days": covering,
"liquidation_days": liquidation,
"fund_signal": round(fund_score, 1), "fund_signal": round(fund_score, 1),
"window": window, "window": window,
} }
return score, detail return score, detail
# ---------------------------------------------------------------------------
# 长期结构 — 30 日窗口,端点 OI 比较
# ---------------------------------------------------------------------------
def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]: def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
if len(df) < window + 1: if len(df) < window + 1:
raise ValueError(f"数据不足,需要至少 {window + 1}") raise ValueError(f"数据不足,需要至少 {window + 1}")
recent_oi = df.iloc[-window:]["oi"] # ── OI 趋势分(端点比较,消除均值滞后)──
avg_oi = float(recent_oi.mean()) oi_now = float(df.iloc[-1]["oi"])
oi_before = float(df.iloc[-window - 1]["oi"]) oi_before = float(df.iloc[-window - 1]["oi"])
oi_change_pct = (oi_now - oi_before) / oi_before if oi_before != 0 else 0.0
oi_change_pct = (avg_oi - oi_before) / oi_before if oi_before != 0 else 0.0
# OI 趋势分 (60%)
oi_score = max(0.0, min(100.0, 50.0 + oi_change_pct * 250)) oi_score = max(0.0, min(100.0, 50.0 + oi_change_pct * 250))
# 价格趋势分 (40%) # ── 价格趋势分 ──
close_now = float(df.iloc[-1]["close"]) close_now = float(df.iloc[-1]["close"])
price_before = float(df.iloc[-window - 1]["close"]) price_before = float(df.iloc[-window - 1]["close"])
price_return_30d = (close_now - price_before) / price_before if price_before != 0 else 0.0 price_return_30d = (close_now - price_before) / price_before if price_before != 0 else 0.0
@@ -136,19 +180,27 @@ def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
score = oi_score * 0.6 + price_score * 0.4 score = oi_score * 0.6 + price_score * 0.4
# 额外统计近 30 日 OI 均值供参考
recent_oi = df.iloc[-window:]["oi"]
detail = { detail = {
"avg_oi": round(avg_oi, 0), "oi_now": round(oi_now, 0),
"oi_before": round(oi_before, 0), "oi_before": round(oi_before, 0),
"change_pct": round(oi_change_pct * 100, 2), "oi_change_pct": round(oi_change_pct * 100, 2),
"oi_score": round(oi_score, 1), "oi_score": round(oi_score, 1),
"price_score": round(price_score, 1), "price_score": round(price_score, 1),
"price_return_30d_pct": round(price_return_30d * 100, 2), "price_return_30d_pct": round(price_return_30d * 100, 2),
"price_before_30d": round(price_before, 2), "price_before_30d": round(price_before, 2),
"avg_oi_30d": round(float(recent_oi.mean()), 0),
"window": window, "window": window,
} }
return score, detail return score, detail
# ---------------------------------------------------------------------------
# 信号解读
# ---------------------------------------------------------------------------
def _interpret(composite: float) -> str: def _interpret(composite: float) -> str:
if composite >= 80: if composite >= 80:
return "强烈看多区域 — 价格与资金共振,趋势多头的温床" return "强烈看多区域 — 价格与资金共振,趋势多头的温床"
@@ -159,6 +211,10 @@ def _interpret(composite: float) -> str:
return "强烈看空区域 — 资金主动且持续地打压价格" return "强烈看空区域 — 资金主动且持续地打压价格"
# ---------------------------------------------------------------------------
# 主打分函数
# ---------------------------------------------------------------------------
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult: def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
"""对 DataFrame 中指定日期或最新一条记录打分。""" """对 DataFrame 中指定日期或最新一条记录打分。"""
if len(df) < 31: if len(df) < 31:
@@ -176,11 +232,12 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
latest = df.iloc[-1] latest = df.iloc[-1]
# ── 三层原始分 ──
short, short_details = calc_short_term(df, 7) short, short_details = calc_short_term(df, 7)
medium, medium_detail = calc_medium_term(df, 15) medium, medium_detail = calc_medium_term(df, 15)
long_, long_detail = calc_long_term(df, 30) long_, long_detail = calc_long_term(df, 30)
# 波动率调整 # ── 波动率(日收益率标准差 + ATR%)──
recent_30 = df.iloc[-30:].copy() recent_30 = df.iloc[-30:].copy()
recent_30["ret"] = recent_30["close"].pct_change() recent_30["ret"] = recent_30["close"].pct_change()
daily_vol = float(recent_30["ret"].std()) daily_vol = float(recent_30["ret"].std())
@@ -197,13 +254,36 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
avg_close_30 = float(recent_30["close"].mean()) avg_close_30 = float(recent_30["close"].mean())
atr_pct = (atr / avg_close_30) if avg_close_30 else 0.0 atr_pct = (atr / avg_close_30) if avg_close_30 else 0.0
# 综合波动率风险度量:日收益标准差 70% + ATR% 30%
vol_risk = daily_vol * 0.7 + atr_pct * 0.3
vol_ref = 0.015 vol_ref = 0.015
if daily_vol <= vol_ref: if vol_risk <= vol_ref:
vol_penalty = 1.0 vol_penalty = 1.0
else: else:
vol_penalty = max(0.85, 1.0 - (daily_vol - vol_ref) * 10) vol_penalty = max(0.85, 1.0 - (vol_risk - vol_ref) * 10)
composite_raw = short * 0.4 + medium * 0.35 + long_ * 0.25 # ── 自适应权重(趋势越强,长期权重越高)──
price_return_30d = (
(float(df.iloc[-1]["close"]) - float(df.iloc[-31]["close"]))
/ float(df.iloc[-31]["close"])
if df.iloc[-31]["close"] != 0
else 0.0
)
# 趋势效率 = |30日收益率| / 日波动率,比率高 = 方向明确
trend_strength = abs(price_return_30d) / max(daily_vol, 0.005)
trend_factor = min(trend_strength / 3.0, 1.0) # 归一化到 [0, 1]
w_short_base = 0.40
w_medium_base = 0.35
w_long_base = 0.25
shift = trend_factor * 0.10 # 最多转移 10% 权重
w_short = w_short_base - shift
w_medium = w_medium_base
w_long = w_long_base + shift
# ── 综合分数 ──
composite_raw = short * w_short + medium * w_medium + long_ * w_long
composite = round(composite_raw * vol_penalty, 1) composite = round(composite_raw * vol_penalty, 1)
signal = _interpret(composite) signal = _interpret(composite)
@@ -218,6 +298,7 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
long_term=round(long_, 1), long_term=round(long_, 1),
composite=round(composite, 1), composite=round(composite, 1),
signal=signal, signal=signal,
vol_penalty=round(float(vol_penalty), 4),
detail=ScoreDetail( detail=ScoreDetail(
short_details=short_details, short_details=short_details,
medium_detail=medium_detail, medium_detail=medium_detail,
@@ -225,7 +306,86 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
volatility={ volatility={
"daily_vol_pct": round(float(daily_vol), 4), "daily_vol_pct": round(float(daily_vol), 4),
"atr_pct": round(float(atr_pct), 4), "atr_pct": round(float(atr_pct), 4),
"vol_risk": round(float(vol_risk), 4),
"vol_penalty": round(float(vol_penalty), 4), "vol_penalty": round(float(vol_penalty), 4),
}, },
adaptive_weights={
"trend_strength": round(float(trend_strength), 2),
"trend_factor": round(float(trend_factor), 2),
"w_short": round(float(w_short), 4),
"w_medium": round(float(w_medium), 4),
"w_long": round(float(w_long), 4),
},
), ),
) )
# ---------------------------------------------------------------------------
# 区间 / 全量打分
# ---------------------------------------------------------------------------
def score_range(
df: pd.DataFrame, start_date: str, end_date: str
) -> tuple[list[ScoreResult], list[str]]:
"""对日期区间内的每一天分别打分,返回 (结果列表, 警告列表)。"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
target_dates = df[
(df["trade_date"].astype(str) >= str(start_date))
& (df["trade_date"].astype(str) <= str(end_date))
]["trade_date"].astype(str).tolist()
for td in target_dates:
try:
result = score_daily(df, td)
results.append(result)
except ValueError as e:
warnings.append(str(e))
_fill_deltas(results)
return results, warnings
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
Returns:
(results, warnings, total_days, scored_count)
"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
for idx in range(30, len(df)):
subset = df.iloc[: idx + 1].copy()
trade_date = str(df.iloc[idx]["trade_date"])
try:
result = score_daily(subset)
results.append(result)
except ValueError as e:
warnings.append(f"{trade_date}: {e}")
_fill_deltas(results)
total_days = len(df) - 30
scored_count = len(results)
return results, warnings, total_days, scored_count
# ---------------------------------------------------------------------------
# 辅助:填充分数动量
# ---------------------------------------------------------------------------
def _fill_deltas(results: list[ScoreResult]):
"""为结果列表中的每个 ScoreResult 填充 composite_delta 和 composite_delta_5d。"""
for i, r in enumerate(results):
if i >= 1 and results[i - 1].composite is not None:
r.composite_delta = round(r.composite - results[i - 1].composite, 1)
if i >= 5 and results[i - 5].composite is not None:
r.composite_delta_5d = round(r.composite - results[i - 5].composite, 1)

View File

@@ -68,6 +68,11 @@ def save_candles(df: pd.DataFrame, db_url: str = DEFAULT_DB_URL):
df = df.copy() df = df.copy()
df = df.where(pd.notna(df), None) df = df.where(pd.notna(df), None)
records = df.to_dict(orient="records") records = df.to_dict(orient="records")
# pandas float 列的 None 会被转为 NaN需手动清理后再存入数据库
for record in records:
for key, val in record.items():
if isinstance(val, float) and val != val: # NaN != NaN
record[key] = None
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany( cur.executemany(
""" """
@@ -98,6 +103,19 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
conn = _get_conn(db_url) conn = _get_conn(db_url)
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
detail_payload = {
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
"volatility": score.detail.volatility,
"adaptive_weights": score.detail.adaptive_weights,
"vol_penalty": score.vol_penalty,
}
if score.composite_delta is not None:
detail_payload["composite_delta"] = score.composite_delta
if score.composite_delta_5d is not None:
detail_payload["composite_delta_5d"] = score.composite_delta_5d
cur.execute( cur.execute(
""" """
INSERT INTO scores INSERT INTO scores
@@ -127,11 +145,7 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
score.long_term, score.long_term,
score.composite, score.composite,
score.signal, score.signal,
json.dumps({ json.dumps(detail_payload, ensure_ascii=False, default=str),
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
}, ensure_ascii=False, default=str),
), ),
) )
conn.commit() conn.commit()

View File

@@ -5,7 +5,8 @@ WORKDIR /ui
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install # 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi RUN npm config set registry https://registry.npmmirror.com && \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./ COPY frontend ./
RUN npm run build RUN npm run build
@@ -21,7 +22,7 @@ WORKDIR /src
COPY backend ./ COPY backend ./
COPY --from=ui /ui/dist ./dist COPY --from=ui /ui/dist ./dist
ENV CGO_ENABLED=0 GOOS=linux ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
RUN go mod tidy && \ RUN go mod tidy && \
go build -trimpath -ldflags="-s -w" -o /out/web ./ go build -trimpath -ldflags="-s -w" -o /out/web ./

View File

@@ -6,6 +6,11 @@ import (
"trade/web/internal/store" "trade/web/internal/store"
) )
// BootstrapLLMConfig 初始化 llm_config 表。
func BootstrapLLMConfig(s *store.FuturesStore) error {
return s.EnsureLLMConfigTable()
}
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin; // Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
// 并强制首次登录后改密码。已存在 admin 时静默跳过。 // 并强制首次登录后改密码。已存在 admin 时静默跳过。
func Bootstrap(s *store.AuthStore) error { func Bootstrap(s *store.AuthStore) error {

View File

@@ -9,6 +9,9 @@ type Config struct {
ListenAddr string ListenAddr string
DatabaseURL string DatabaseURL string
TushareAPIURL string TushareAPIURL string
LLMBaseURL string
LLMAPIKey string
LLMModel string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -16,6 +19,9 @@ func Load() (*Config, error) {
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"), TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
LLMBaseURL: getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMModel: getenv("LLM_MODEL", "deepseek-chat"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")

View File

@@ -0,0 +1,307 @@
package handlers
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"trade/web/internal/store"
)
// AIConfig LLM 调用配置。
type AIConfig struct {
BaseURL string
APIKey string
Model string
}
// resolveLLMConfig 返回实际使用的 LLM 配置DB 优先,环境变量作 fallback。
func (d *Deps) resolveLLMConfig() *AIConfig {
cfg := &AIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
}
// 尝试从 DB 读取
if dbCfg, err := d.Futures.GetLLMConfig(); err == nil && dbCfg != nil && dbCfg.APIKey != "" {
cfg.APIKey = dbCfg.APIKey
if dbCfg.BaseURL != "" {
cfg.BaseURL = dbCfg.BaseURL
}
if dbCfg.Model != "" {
cfg.Model = dbCfg.Model
}
return cfg
}
// fallback 到环境变量
if d.AIConfig != nil {
if d.AIConfig.APIKey != "" {
cfg.APIKey = d.AIConfig.APIKey
}
if d.AIConfig.BaseURL != "" {
cfg.BaseURL = d.AIConfig.BaseURL
}
if d.AIConfig.Model != "" {
cfg.Model = d.AIConfig.Model
}
}
return cfg
}
// Analyze 接收 ts_code + trade_date查库拼 prompt调 LLM 并以 SSE 流式返回。
func (d *Deps) Analyze(w http.ResponseWriter, r *http.Request) {
llmCfg := d.resolveLLMConfig()
if llmCfg.APIKey == "" {
writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置")
return
}
q := r.URL.Query()
tsCode := q.Get("ts_code")
tradeDate := q.Get("trade_date")
if tsCode == "" || tradeDate == "" {
writeErr(w, http.StatusBadRequest, "缺少 ts_code 或 trade_date")
return
}
ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
prompt := buildPrompt(ctx)
// SSE
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "不支持 SSE")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
if err := streamLLM(llmCfg, prompt, w, flusher); err != nil {
log.Printf("[ai] stream error: %v", err)
sendSSE(w, flusher, "error", err.Error())
}
sendSSE(w, flusher, "done", "")
}
func sendSSE(w io.Writer, flusher http.Flusher, event, data string) {
if event != "" {
fmt.Fprintf(w, "event: %s\n", event)
}
for _, line := range strings.Split(data, "\n") {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprint(w, "\n")
flusher.Flush()
}
// streamLLM 调用 LLM chat/completions逐 token 推送 SSE。
func streamLLM(cfg *AIConfig, prompt []map[string]string, w io.Writer, flusher http.Flusher) error {
body := map[string]any{
"model": cfg.Model,
"messages": prompt,
"stream": true,
}
payload, _ := json.Marshal(body)
req, err := http.NewRequest("POST", strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b))
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // 忽略解析失败的行
}
for _, c := range chunk.Choices {
if c.Delta.Content != "" {
sendSSE(w, flusher, "token", c.Delta.Content)
}
}
}
return scanner.Err()
}
// buildPrompt 用 AnalysisContext 构建 system + user 消息。
func buildPrompt(ctx *store.AnalysisContext) []map[string]string {
s := ctx.Score
// 解析 detail_json 中的关键字段
var detail struct {
ShortDetails []map[string]any `json:"short_details"`
MediumDetail map[string]any `json:"medium_detail"`
LongDetail map[string]any `json:"long_detail"`
Volatility map[string]any `json:"volatility"`
AdaptiveW map[string]any `json:"adaptive_weights"`
VolPenalty float64 `json:"vol_penalty"`
Delta1D *float64 `json:"composite_delta"`
Delta5D *float64 `json:"composite_delta_5d"`
}
if s.Detail != nil {
_ = json.Unmarshal(s.Detail, &detail)
}
var sb strings.Builder
sb.WriteString("你是一位资深期货技术分析师,擅长从量化打分系统中解读市场信号。\n")
// 综合
sb.WriteString(fmt.Sprintf("\n## 合约 %s %s\n", s.TsCode, s.TradeDate))
sb.WriteString(fmt.Sprintf("- 收盘 %.2f 持仓 %.0f (日变动 %+.0f)\n", s.Close, s.OI, s.OIChg))
sb.WriteString(fmt.Sprintf("- 综合分 **%.1f** / 100\n", s.Composite))
sb.WriteString(fmt.Sprintf("- 分层: 短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm))
sb.WriteString(fmt.Sprintf("- 信号: %s\n", s.Signal))
// 波动率
if v, ok := detail.Volatility["vol_penalty"]; ok {
dp := 0.0
if vv, ok := v.(float64); ok {
dp = vv
}
risk := 0.0
if vr, ok := detail.Volatility["vol_risk"]; ok {
if vv, ok := vr.(float64); ok {
risk = vv
}
}
sb.WriteString(fmt.Sprintf("- 波动率惩罚系数: %.3f (综合风险 %.2f%%)\n", dp, risk*100))
}
// 自适应权重
if aw, ok := detail.AdaptiveW["trend_strength"]; ok {
sb.WriteString(fmt.Sprintf("- 趋势强度: %.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n",
aw, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long")))
}
// 分数动量
if detail.Delta1D != nil {
sb.WriteString(fmt.Sprintf("- 分数动量: Δ1d %+.1f", *detail.Delta1D))
}
if detail.Delta5D != nil {
sb.WriteString(fmt.Sprintf(" Δ5d %+.1f", *detail.Delta5D))
}
if detail.Delta1D != nil || detail.Delta5D != nil {
sb.WriteString("\n")
}
// 短期细节
sb.WriteString("\n## 短期动力 (近7日逐日)\n")
sb.WriteString("| 日期 | 象限 | 涨跌% | OI变化% | 得分 |\n")
sb.WriteString("|------|------|-------|---------|------|\n")
for _, d := range detail.ShortDetails {
q := fmt.Sprint(d["quadrant"])
qcn := map[string]string{
"accumulation": "增仓涨", "distribution": "增仓跌",
"covering": "减仓涨", "liquidation": "减仓跌", "flat": "持平",
}[q]
if qcn == "" {
qcn = q
}
sb.WriteString(fmt.Sprintf("| %s | %s | %+.2f%% | %+.2f%% | %.1f |\n",
d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "score")))
}
// 中期细节
md := detail.MediumDetail
sb.WriteString("\n## 中期趋势 (15日)\n")
sb.WriteString(fmt.Sprintf("- 价格信号: %.1f (收益率 %+.2f%%)\n", floatVal(md, "price_signal"), floatVal(md, "price_return_pct")))
sb.WriteString(fmt.Sprintf("- 资金意愿: %.1f\n", floatVal(md, "fund_signal")))
a, d_, c, l := intVal(md, "accumulation_days"), intVal(md, "distribution_days"), intVal(md, "covering_days"), intVal(md, "liquidation_days")
sb.WriteString(fmt.Sprintf("- 象限分布: 增仓涨 %d天 / 增仓跌 %d天 / 减仓涨 %d天 / 减仓跌 %d天\n", a, d_, c, l))
// 长期细节
ld := detail.LongDetail
sb.WriteString("\n## 长期结构 (30日)\n")
sb.WriteString(fmt.Sprintf("- OI 趋势分: %.1f (端点变化 %+.2f%%)\n", floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct")))
sb.WriteString(fmt.Sprintf("- 价格趋势分: %.1f (30日收益 %+.2f%%)\n", floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct")))
// 近 5 日分数趋势
if len(ctx.RecentScores) > 0 {
sb.WriteString("\n## 近5日分数趋势\n")
for i, rs := range ctx.RecentScores {
if i >= 5 {
break
}
sb.WriteString(fmt.Sprintf("- %s 综合 %.1f\n", rs.TradeDate, rs.Composite))
}
}
// 近30日K线数据供支撑阻力分析
if len(ctx.Candles) > 0 {
start := 0
if len(ctx.Candles) > 30 {
start = len(ctx.Candles) - 30
}
sb.WriteString("\n## 近30日K线开/高/低/收)\n")
sb.WriteString("| 日期 | 开盘 | 最高 | 最低 | 收盘 |\n")
sb.WriteString("|------|------|------|------|------|\n")
for _, c := range ctx.Candles[start:] {
sb.WriteString(fmt.Sprintf("| %s | %.1f | %.1f | %.1f | %.1f |\n",
c.TradeDate, c.Open, c.High, c.Low, c.Close))
}
}
sb.WriteString("\n请从以下4个角度简要分析使用中文\n")
sb.WriteString("1. 当前多空格局2-3句话\n")
sb.WriteString("2. 资金行为特征2-3句话\n")
sb.WriteString("3. 关键风险点2-3句话\n")
sb.WriteString("4. 支撑与阻力明确指出最近的关键支撑位和阻力位基于近30日高低点和均线位置给出具体价位和依据\n")
return []map[string]string{
{"role": "user", "content": sb.String()},
}
}
func floatVal(m map[string]any, key string) float64 {
v, _ := m[key].(float64)
return v
}
func intVal(m map[string]any, key string) int {
v, _ := m[key].(float64)
return int(v)
}
func valPct(m map[string]any, key string) float64 {
v, _ := m[key].(float64)
return v * 100
}

View File

@@ -0,0 +1,409 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"trade/web/internal/store"
)
// 所有已配置品种(与 tushare/src/contracts.py 保持一致)。
var allSymbols = []string{"FG", "SA", "RB", "MA", "CF", "M"}
type ddRunRequest struct {
TradeDate string `json:"trade_date,omitempty"` // YYYYMMDD, 默认今天
Symbols []string `json:"symbols,omitempty"` // 默认全部
}
type ddRunResponse struct {
TradeDate string `json:"trade_date"`
Results []ddSymbolResult `json:"results"`
Errors []ddSymbolResult `json:"errors,omitempty"`
}
type ddSymbolResult struct {
Symbol string `json:"symbol"`
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Error string `json:"error,omitempty"`
}
// DailyDirectionRun 对多个品种批量执行 AI 方向分析。
func (d *Deps) DailyDirectionRun(w http.ResponseWriter, r *http.Request) {
var req ddRunRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.TradeDate == "" {
req.TradeDate = time.Now().Format("20060102")
}
if len(req.Symbols) == 0 {
req.Symbols = allSymbols
}
llmCfg := d.resolveLLMConfig()
if llmCfg.APIKey == "" {
writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置")
return
}
resp := ddRunResponse{TradeDate: req.TradeDate}
for _, sym := range req.Symbols {
result, err := d.analyzeOneDirection(llmCfg, sym, req.TradeDate)
if err != nil {
resp.Errors = append(resp.Errors, ddSymbolResult{Symbol: sym, Error: err.Error()})
continue
}
resp.Results = append(resp.Results, *result)
}
writeJSON(w, http.StatusOK, resp)
}
// analyzeOneDirection 对单个品种做方向分析并持久化。
func (d *Deps) analyzeOneDirection(llmCfg *AIConfig, symbol, tradeDate string) (*ddSymbolResult, error) {
// 1. 找到活跃合约
tsCode, err := d.Futures.GetActiveTsCode(symbol, tradeDate)
if err != nil {
return nil, fmt.Errorf("查找活跃合约: %w", err)
}
// 2. 拉取分析上下文
ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("获取分析数据: %w", err)
}
// 3. 构建方向分析 prompt
prompt := buildDirectionPrompt(ctx, symbol)
promptSnapshot := prompt[len(prompt)-1]["content"]
// 4. 调 LLM非流式
rawJSON, err := callLLM(llmCfg, prompt)
if err != nil {
return nil, fmt.Errorf("LLM 调用失败: %w", err)
}
// 5. 解析 JSON
parsed, err := parseDirectionJSON(rawJSON)
if err != nil {
log.Printf("[daily-direction] %s JSON parse failed, raw: %s", symbol, rawJSON)
return nil, fmt.Errorf("AI 返回格式异常: %w", err)
}
// 6. 计算 target_date简化+1天周五 +3天
targetDate := nextTradeDate(tradeDate)
// 7. 持久化
supportJSON, _ := json.Marshal(parsed.Support)
resistJSON, _ := json.Marshal(parsed.Resistance)
dd := &store.DailyDirection{
Symbol: symbol,
TradeDate: tradeDate,
TargetDate: targetDate,
Direction: parsed.Direction,
Confidence: parsed.Confidence,
Support: string(supportJSON),
Resistance: string(resistJSON),
Reasoning: parsed.Reasoning,
RiskNote: parsed.RiskNote,
PromptSnapshot: promptSnapshot,
}
if err := d.Futures.SaveDailyDirection(dd); err != nil {
return nil, fmt.Errorf("保存失败: %w", err)
}
return &ddSymbolResult{
Symbol: symbol,
Direction: parsed.Direction,
Confidence: parsed.Confidence,
}, nil
}
// directionJSON LLM 返回的期望结构。
type directionJSON struct {
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Support []float64 `json:"support"`
Resistance []float64 `json:"resistance"`
Reasoning string `json:"reasoning"`
RiskNote string `json:"risk_note"`
}
// parseDirectionJSON 从 LLM 原始响应中提取结构化结果。
func parseDirectionJSON(raw string) (*directionJSON, error) {
raw = strings.TrimSpace(raw)
// 尝试直接解析
var d directionJSON
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
// 尝试提取 markdown code block 中的 JSON
if idx := strings.Index(raw, "```json"); idx >= 0 {
rest := raw[idx+7:]
if end := strings.Index(rest, "```"); end > 0 {
raw = strings.TrimSpace(rest[:end])
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
}
}
if idx := strings.Index(raw, "```"); idx >= 0 {
rest := raw[idx+3:]
if end := strings.Index(rest, "```"); end > 0 {
raw = strings.TrimSpace(rest[:end])
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
}
}
return nil, fmt.Errorf("无法解析 AI 返回的 JSON")
}
func (d *directionJSON) validate() error {
d.Direction = strings.TrimSpace(strings.ToLower(d.Direction))
switch d.Direction {
case "bullish", "bearish", "neutral":
default:
return fmt.Errorf("无效的 direction 值: %s", d.Direction)
}
if d.Confidence < 0 || d.Confidence > 100 {
d.Confidence = max(0, min(100, d.Confidence))
}
return nil
}
// callLLM 非流式调用 LLM返回 content 文本。
func callLLM(cfg *AIConfig, prompt []map[string]string) (string, error) {
body := map[string]any{
"model": cfg.Model,
"messages": prompt,
"stream": false,
}
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 120 * time.Second}
req, err := http.NewRequest("POST", strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode llm response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("llm returned empty choices")
}
return result.Choices[0].Message.Content, nil
}
// buildDirectionPrompt 构建方向分析专用 prompt。
func buildDirectionPrompt(ctx *store.AnalysisContext, symbol string) []map[string]string {
s := ctx.Score
var detail struct {
ShortDetails []map[string]any `json:"short_details"`
MediumDetail map[string]any `json:"medium_detail"`
LongDetail map[string]any `json:"long_detail"`
Volatility map[string]any `json:"volatility"`
AdaptiveW map[string]any `json:"adaptive_weights"`
VolPenalty float64 `json:"vol_penalty"`
Delta1D *float64 `json:"composite_delta"`
Delta5D *float64 `json:"composite_delta_5d"`
}
if s.Detail != nil {
_ = json.Unmarshal(s.Detail, &detail)
}
symbolNames := map[string]string{
"FG": "玻璃", "SA": "纯碱", "RB": "螺纹钢", "MA": "甲醇", "CF": "棉花", "M": "豆粕",
}
symbolName := symbolNames[symbol]
if symbolName == "" {
symbolName = symbol
}
var sb strings.Builder
// ── 综合概况 ──
sb.WriteString(fmt.Sprintf("品种:%s(%s)\n", symbolName, symbol))
sb.WriteString(fmt.Sprintf("合约:%s 日期:%s\n", s.TsCode, s.TradeDate))
sb.WriteString(fmt.Sprintf("收盘:%.2f 持仓:%.0f (日变动 %+.0f)\n", s.Close, s.OI, s.OIChg))
sb.WriteString(fmt.Sprintf("综合分:%.1f/100 信号:%s\n", s.Composite, s.Signal))
sb.WriteString(fmt.Sprintf("分层:短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm))
// 波动率
if v, ok := detail.Volatility["vol_penalty"]; ok {
risk := 0.0
if vr, ok := detail.Volatility["vol_risk"]; ok {
if vv, ok := vr.(float64); ok {
risk = vv
}
}
sb.WriteString(fmt.Sprintf("波动率惩罚:%.3f (风险 %.2f%%)\n", v, risk*100))
}
// 自适应权重
if ts, ok := detail.AdaptiveW["trend_strength"]; ok {
sb.WriteString(fmt.Sprintf("趋势强度:%.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n",
ts, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long")))
}
// 分数动量
if detail.Delta1D != nil {
sb.WriteString(fmt.Sprintf("分数日变化:%+.1f", *detail.Delta1D))
}
if detail.Delta5D != nil {
sb.WriteString(fmt.Sprintf(" 周变化:%+.1f", *detail.Delta5D))
}
if detail.Delta1D != nil || detail.Delta5D != nil {
sb.WriteString("\n")
}
// ── 短期动力 7 日 ──
sb.WriteString("\n近7日逐日打分\n")
for _, d := range detail.ShortDetails {
q := fmt.Sprint(d["quadrant"])
qcn := map[string]string{
"accumulation": "增仓涨", "distribution": "增仓跌",
"covering": "减仓涨", "liquidation": "减仓跌", "flat": "持平",
}[q]
if qcn == "" {
qcn = q
}
sb.WriteString(fmt.Sprintf(" %s %s 涨跌%+.2f%% OI%+.2f%% 量比%.2f 得分%.1f\n",
d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "vol_ratio"), floatVal(d, "score")))
}
// ── 中期趋势 15 日 ──
md := detail.MediumDetail
sb.WriteString(fmt.Sprintf("\n中期趋势(15日):价格信号 %.1f (收益率 %+.2f%%) 资金意愿 %.1f\n",
floatVal(md, "price_signal"), floatVal(md, "price_return_pct"), floatVal(md, "fund_signal")))
a, d_, c, l := intVal(md, "accumulation_days"), intVal(md, "distribution_days"), intVal(md, "covering_days"), intVal(md, "liquidation_days")
sb.WriteString(fmt.Sprintf("象限分布:增仓涨 %d天 增仓跌 %d天 减仓涨 %d天 减仓跌 %d天\n", a, d_, c, l))
// ── 长期结构 30 日 ──
ld := detail.LongDetail
sb.WriteString(fmt.Sprintf("\n长期结构(30日)OI趋势分 %.1f (变化 %+.2f%%) 价格趋势分 %.1f (收益 %+.2f%%)\n",
floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct"), floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct")))
// ── 近 5 日分数趋势 ──
if len(ctx.RecentScores) > 0 {
sb.WriteString("\n近5日综合分趋势")
for i, rs := range ctx.RecentScores {
if i >= 5 {
break
}
sb.WriteString(fmt.Sprintf("%s=%.1f ", rs.TradeDate, rs.Composite))
}
sb.WriteString("\n")
}
// ── 近 30 日 K 线(支撑阻力依据)──
if len(ctx.Candles) > 0 {
start := 0
if len(ctx.Candles) > 30 {
start = len(ctx.Candles) - 30
}
sb.WriteString("\n近30日K线开/高/低/收):\n")
for _, c := range ctx.Candles[start:] {
sb.WriteString(fmt.Sprintf(" %s O%.1f H%.1f L%.1f C%.1f\n", c.TradeDate, c.Open, c.High, c.Low, c.Close))
}
}
systemPrompt := strings.Join([]string{
"你是一位期货日内交易分析师。基于量化打分数据,给出下一个交易日的方向判断。",
"",
"你必须输出严格 JSON不要有任何额外文字",
"{",
` "direction": "bullish" | "bearish" | "neutral",`,
` "confidence": <0-100 的整数,表示对方向的确定程度>`,
` "support": [<关键支撑位1>, <关键支撑位2>]`,
` "resistance": [<关键阻力位1>, <关键阻力位2>]`,
` "reasoning": "<3-5句话说明核心逻辑三层信号是共振还是背离资金在干什么>",`,
` "risk_note": "<1-2句话最可能打破当前判断的风险>"`,
"}",
"",
"分析要点:",
"1. 短期(7日)+中期(15日)+长期(30日) ≥2 层指向同一方向 = 有效方向信号",
"2. 关注持仓变化:增仓方向才是真方向,减仓方向可能是离场",
"3. 分数动量Δ1d 和 Δ5d 连续同向 = 趋势加速,反向 = 可能转折",
"4. 支撑阻力从 K 线数据中找:近期高低点、密集成交区",
"5. 波动率惩罚系数 < 0.95 表示行情不稳定,降低 confidence",
}, "\n")
return []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": sb.String()},
}
}
// nextTradeDate 计算下一个交易日(简化规则:周六→周一,周日→周一,周五→周一,其余+1天
func nextTradeDate(dateStr string) string {
t, err := time.Parse("20060102", dateStr)
if err != nil {
return dateStr
}
next := t.AddDate(0, 0, 1)
switch next.Weekday() {
case time.Saturday:
next = next.AddDate(0, 0, 2)
case time.Sunday:
next = next.AddDate(0, 0, 1)
}
return next.Format("20060102")
}
// ListDailyDirections 查询方向分析列表。
func (d *Deps) ListDailyDirections(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
symbol := q.Get("symbol")
start := q.Get("start")
end := q.Get("end")
limit := 50
if l, err := strconv.Atoi(q.Get("limit")); err == nil {
limit = l
}
items, err := d.Futures.ListDailyDirections(symbol, start, end, limit)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}

View File

@@ -13,6 +13,7 @@ type Deps struct {
Auth *store.AuthStore Auth *store.AuthStore
Futures *store.FuturesStore Futures *store.FuturesStore
TushareURL string TushareURL string
AIConfig *AIConfig
} }
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {

View File

@@ -0,0 +1,86 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"trade/web/internal/store"
)
type llmConfigReq struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
type llmConfigResp struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"` // 脱敏:仅显示首尾
Model string `json:"model"`
HasAPIKey bool `json:"has_api_key"` // 前端据此判断是否已配置
}
// maskKey 将 sk-abc123xyz 脱敏为 sk-a...xyz。
func maskKey(key string) string {
if len(key) <= 8 {
return strings.Repeat("*", len(key))
}
return key[:4] + "..." + key[len(key)-4:]
}
// GetLLMConfig 返回当前 LLM 配置API Key 脱敏)。
func (d *Deps) GetLLMConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := d.Futures.GetLLMConfig()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
resp := llmConfigResp{
BaseURL: cfg.BaseURL,
Model: cfg.Model,
HasAPIKey: cfg.APIKey != "",
}
if cfg.APIKey != "" {
resp.APIKey = maskKey(cfg.APIKey)
}
writeJSON(w, http.StatusOK, resp)
}
// SaveLLMConfig 保存 LLM 配置。api_key 为脱敏占位时保留原值。
func (d *Deps) SaveLLMConfig(w http.ResponseWriter, r *http.Request) {
var req llmConfigReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
req.BaseURL = strings.TrimSpace(req.BaseURL)
req.Model = strings.TrimSpace(req.Model)
if req.BaseURL == "" {
req.BaseURL = "https://api.deepseek.com/v1"
}
if req.Model == "" {
req.Model = "deepseek-chat"
}
// 如果前端传的是脱敏值(含 ...),说明未修改 Key保留旧值
if strings.Contains(req.APIKey, "...") {
old, err := d.Futures.GetLLMConfig()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
req.APIKey = old.APIKey
}
cfg := &store.LLMConfig{
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Model: req.Model,
}
if err := d.Futures.SaveLLMConfig(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

View File

@@ -56,6 +56,72 @@ func (d *Deps) RunBatch(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
type runRangeRequest struct {
Symbol string `json:"symbol,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
var req runRangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 180 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/range", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
type runFullRequest struct {
TsCode string `json:"ts_code"`
}
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
var req runFullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.TsCode == "" {
writeErr(w, http.StatusBadRequest, "ts_code is required")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 300 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) { func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol") symbol := r.URL.Query().Get("symbol")
if symbol == "" { if symbol == "" {

View File

@@ -39,6 +39,13 @@ func (r *statusRecorder) WriteHeader(code int) {
r.ResponseWriter.WriteHeader(code) r.ResponseWriter.WriteHeader(code)
} }
// Flush 透传 http.Flusher,避免 SSE 流式响应被中间件阻断。
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -32,6 +32,11 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
r.Get("/candles", d.ListCandles) r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline) r.Post("/run", d.RunPipeline)
r.Post("/run/batch", d.RunBatch) r.Post("/run/batch", d.RunBatch)
r.Post("/run/range", d.RunRange)
r.Post("/run/full", d.RunFull)
r.Get("/ai/analyze", d.Analyze)
r.Post("/ai/daily-direction", d.DailyDirectionRun)
r.Get("/ai/daily-direction", d.ListDailyDirections)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
@@ -40,6 +45,8 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
r.Patch("/admin/users/{id}", d.AdminPatchUser) r.Patch("/admin/users/{id}", d.AdminPatchUser)
r.Delete("/admin/users/{id}", d.AdminDeleteUser) r.Delete("/admin/users/{id}", d.AdminDeleteUser)
r.Post("/admin/reset-data", d.AdminResetData) r.Post("/admin/reset-data", d.AdminResetData)
r.Get("/admin/llm-config", d.GetLLMConfig)
r.Put("/admin/llm-config", d.SaveLLMConfig)
}) })
}) })
}) })

View File

@@ -0,0 +1,134 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
)
// AnalysisContext 汇总一次 AI 分析所需的全部数据。
type AnalysisContext struct {
Score Score `json:"score"`
Candles []Candle `json:"candles"`
RecentScores []Score `json:"recent_scores"`
}
// GetAnalysisContext 拉取指定合约某日的 score + 近 60 日 K 线 + 近 10 日 scores。
func (s *FuturesStore) GetAnalysisContext(tsCode, tradeDate string) (*AnalysisContext, error) {
ctx := &AnalysisContext{}
// 1) 目标日 score含 detail_json
row := s.db.QueryRow(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, detail_json, created_at FROM scores
WHERE ts_code = $1 AND trade_date = $2`, tsCode, tradeDate)
var detail sql.NullString
if err := row.Scan(&ctx.Score.ID, &ctx.Score.TsCode, &ctx.Score.TradeDate,
&ctx.Score.Close, &ctx.Score.OI, &ctx.Score.OIChg,
&ctx.Score.ShortTerm, &ctx.Score.MediumTerm, &ctx.Score.LongTerm,
&ctx.Score.Composite, &ctx.Score.Signal, &detail, &ctx.Score.CreatedAt); err != nil {
return nil, fmt.Errorf("score not found: %w", err)
}
if detail.Valid {
ctx.Score.Detail = json.RawMessage(detail.String)
}
// 2) 近 60 日 K 线
rows, err := s.db.Query(`SELECT ts_code, trade_date,
COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0),
COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0),
COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0),
COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0),
COALESCE(NULLIF(pre_close, 'NaN'::real), 0)
FROM candles WHERE ts_code = $1 AND trade_date <= $2
ORDER BY trade_date DESC LIMIT 60`, tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("candles: %w", err)
}
defer rows.Close()
for rows.Next() {
var c Candle
if err := rows.Scan(&c.TsCode, &c.TradeDate, &c.Open, &c.High, &c.Low, &c.Close,
&c.Vol, &c.Amount, &c.OI, &c.OIChg, &c.PreClose); err != nil {
return nil, err
}
ctx.Candles = append(ctx.Candles, c)
}
// 反转回升序
for i, j := 0, len(ctx.Candles)-1; i < j; i, j = i+1, j-1 {
ctx.Candles[i], ctx.Candles[j] = ctx.Candles[j], ctx.Candles[i]
}
// 3) 近 10 日 scores不含 detail_json 以减体积)
scoreRows, err := s.db.Query(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, created_at FROM scores
WHERE ts_code = $1 AND trade_date <= $2
ORDER BY trade_date DESC LIMIT 10`, tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("recent scores: %w", err)
}
defer scoreRows.Close()
for scoreRows.Next() {
var sc Score
if err := scoreRows.Scan(&sc.ID, &sc.TsCode, &sc.TradeDate, &sc.Close, &sc.OI, &sc.OIChg,
&sc.ShortTerm, &sc.MediumTerm, &sc.LongTerm, &sc.Composite, &sc.Signal, &sc.CreatedAt); err != nil {
return nil, err
}
ctx.RecentScores = append(ctx.RecentScores, sc)
}
// 反转回升序
for i, j := 0, len(ctx.RecentScores)-1; i < j; i, j = i+1, j-1 {
ctx.RecentScores[i], ctx.RecentScores[j] = ctx.RecentScores[j], ctx.RecentScores[i]
}
return ctx, nil
}
// LLMConfig 数据库中的 LLM 配置单例。
type LLMConfig struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
// EnsureLLMConfigTable 建 llm_config 表(幂等)。
func (s *FuturesStore) EnsureLLMConfigTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK(id = 1),
base_url TEXT NOT NULL DEFAULT 'https://api.deepseek.com/v1',
api_key TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT 'deepseek-chat',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO llm_config (id) VALUES (1) ON CONFLICT DO NOTHING;
`)
return err
}
// GetLLMConfig 读取 LLM 配置,无记录返回零值。
func (s *FuturesStore) GetLLMConfig() (*LLMConfig, error) {
cfg := &LLMConfig{}
err := s.db.QueryRow(`SELECT base_url, api_key, model FROM llm_config WHERE id = 1`).
Scan(&cfg.BaseURL, &cfg.APIKey, &cfg.Model)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return cfg, nil
}
return nil, err
}
return cfg, nil
}
// SaveLLMConfig 写入 LLM 配置upsert
func (s *FuturesStore) SaveLLMConfig(cfg *LLMConfig) error {
_, err := s.db.Exec(`
INSERT INTO llm_config (id, base_url, api_key, model, updated_at)
VALUES (1, $1, $2, $3, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE SET
base_url = EXCLUDED.base_url,
api_key = EXCLUDED.api_key,
model = EXCLUDED.model,
updated_at = CURRENT_TIMESTAMP
`, cfg.BaseURL, cfg.APIKey, cfg.Model)
return err
}

View File

@@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
args = append(args, "%"+f.Signal+"%") args = append(args, "%"+f.Signal+"%")
} }
q += " ORDER BY trade_date DESC, id DESC" q += " ORDER BY trade_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 500 { if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200 f.Limit = 200
} }
q += " LIMIT " + next() q += " LIMIT " + next()
@@ -144,14 +144,136 @@ type Candle struct {
PreClose float64 `json:"pre_close"` PreClose float64 `json:"pre_close"`
} }
// ── Daily Direction ────────────────────────────────────────────────
// DailyDirection 日内方向分析结果。
type DailyDirection struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
TradeDate string `json:"trade_date"`
TargetDate string `json:"target_date"`
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Support string `json:"support"` // JSONB → string
Resistance string `json:"resistance"` // JSONB → string
Reasoning string `json:"reasoning"`
RiskNote string `json:"risk_note"`
PromptSnapshot string `json:"prompt_snapshot,omitempty"`
CreatedAt string `json:"created_at"`
}
// EnsureDailyDirectionTable 建 daily_direction 表(幂等)。
func (s *FuturesStore) EnsureDailyDirectionTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS daily_direction (
id UUID DEFAULT uuidv7() PRIMARY KEY,
symbol TEXT NOT NULL,
trade_date TEXT NOT NULL,
target_date TEXT NOT NULL,
direction TEXT NOT NULL,
confidence REAL,
support JSONB,
resistance JSONB,
reasoning TEXT,
risk_note TEXT,
prompt_snapshot TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (symbol, trade_date)
)
`)
return err
}
// SaveDailyDirection 写入upsert一条方向分析。
func (s *FuturesStore) SaveDailyDirection(dd *DailyDirection) error {
_, err := s.db.Exec(`
INSERT INTO daily_direction
(symbol, trade_date, target_date, direction, confidence, support, resistance, reasoning, risk_note, prompt_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (symbol, trade_date) DO UPDATE SET
target_date = EXCLUDED.target_date,
direction = EXCLUDED.direction,
confidence = EXCLUDED.confidence,
support = EXCLUDED.support,
resistance = EXCLUDED.resistance,
reasoning = EXCLUDED.reasoning,
risk_note = EXCLUDED.risk_note,
prompt_snapshot = EXCLUDED.prompt_snapshot,
created_at = CURRENT_TIMESTAMP
`, dd.Symbol, dd.TradeDate, dd.TargetDate, dd.Direction, dd.Confidence,
dd.Support, dd.Resistance, dd.Reasoning, dd.RiskNote, dd.PromptSnapshot)
return err
}
// ListDailyDirections 查询方向分析列表。
func (s *FuturesStore) ListDailyDirections(symbol, start, end string, limit int) ([]DailyDirection, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
q := `SELECT id, symbol, trade_date, target_date, direction, confidence,
COALESCE(support::text, '[]'), COALESCE(resistance::text, '[]'),
reasoning, risk_note, COALESCE(prompt_snapshot, ''),
COALESCE(created_at::text, '')
FROM daily_direction WHERE 1=1`
args := []any{}
n := 0
next := func() string { n++; return fmt.Sprintf("$%d", n) }
if symbol != "" {
q += " AND symbol = " + next()
args = append(args, symbol)
}
if start != "" {
q += " AND trade_date >= " + next()
args = append(args, start)
}
if end != "" {
q += " AND trade_date <= " + next()
args = append(args, end)
}
q += " ORDER BY trade_date DESC, symbol ASC LIMIT " + next()
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DailyDirection{}
for rows.Next() {
var dd DailyDirection
if err := rows.Scan(&dd.ID, &dd.Symbol, &dd.TradeDate, &dd.TargetDate,
&dd.Direction, &dd.Confidence, &dd.Support, &dd.Resistance,
&dd.Reasoning, &dd.RiskNote, &dd.PromptSnapshot, &dd.CreatedAt); err != nil {
return nil, err
}
out = append(out, dd)
}
return out, rows.Err()
}
// GetActiveTsCode 通过 scores 表查找某品种在指定日期的活跃合约代码。
func (s *FuturesStore) GetActiveTsCode(symbol, tradeDate string) (string, error) {
var tsCode string
err := s.db.QueryRow(
`SELECT ts_code FROM scores WHERE trade_date = $1 AND ts_code LIKE $2 || '%' ORDER BY ts_code DESC LIMIT 1`,
tradeDate, symbol,
).Scan(&tsCode)
if err != nil {
return "", fmt.Errorf("no active contract for %s on %s: %w", symbol, tradeDate, err)
}
return tsCode, nil
}
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) { func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
if tsCode == "" { if tsCode == "" {
return nil, ErrMissingTsCode return nil, ErrMissingTsCode
} }
q := `SELECT ts_code, trade_date, q := `SELECT ts_code, trade_date,
COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0), COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0),
COALESCE(vol, 0), COALESCE(amount, 0), COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0),
COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0) COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0),
COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0),
COALESCE(NULLIF(pre_close, 'NaN'::real), 0)
FROM candles WHERE ts_code = $1` FROM candles WHERE ts_code = $1`
args := []any{tsCode} args := []any{tsCode}
n := 1 n := 1

View File

@@ -40,7 +40,23 @@ func main() {
log.Fatalf("bootstrap: %v", err) log.Fatalf("bootstrap: %v", err)
} }
deps := &handlers.Deps{Auth: authDB, Futures: futures, TushareURL: cfg.TushareAPIURL} if err := auth.BootstrapLLMConfig(futures); err != nil {
log.Fatalf("bootstrap llm config: %v", err)
}
if err := futures.EnsureDailyDirectionTable(); err != nil {
log.Fatalf("bootstrap daily_direction: %v", err)
}
deps := &handlers.Deps{
Auth: authDB,
Futures: futures,
TushareURL: cfg.TushareAPIURL,
AIConfig: &handlers.AIConfig{
BaseURL: cfg.LLMBaseURL,
APIKey: cfg.LLMAPIKey,
Model: cfg.LLMModel,
},
}
dist, err := fs.Sub(distFS, "dist") dist, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"marked": "^15.0.0",
"element-plus": "^2.8.4", "element-plus": "^2.8.4",
"pinia": "^2.2.4", "pinia": "^2.2.4",
"vue": "^3.5.10", "vue": "^3.5.10",

View File

@@ -6,7 +6,6 @@ import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
import { resetAllData } from '@/api/admin' import { resetAllData } from '@/api/admin'
import { runBatch } from '@/api/run'
const auth = useAuthStore() const auth = useAuthStore()
const theme = useThemeStore() const theme = useThemeStore()
@@ -16,7 +15,6 @@ const { isMobile } = useMobile()
const drawerOpen = ref(false) const drawerOpen = ref(false)
const resetting = ref(false) const resetting = ref(false)
const syncing = ref(false)
const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token) const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token)
@@ -60,22 +58,6 @@ async function handleReset() {
} }
} }
async function handleSync() {
syncing.value = true
try {
await runBatch()
ElMessage.success('同步完成')
if (route.path === '/scores') {
router.go(0)
} else {
router.push('/scores')
}
} catch {
ElMessage.error('同步失败')
} finally {
syncing.value = false
}
}
</script> </script>
<template> <template>
@@ -90,12 +72,11 @@ async function handleSync() {
:text-color="menuColors.text" :text-color="menuColors.text"
:active-text-color="menuColors.active" :active-text-color="menuColors.active"
> >
<el-menu-item index="/scores">打分列表</el-menu-item> <el-menu-item index="/scores">品种打分</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item> <el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleSync" :disabled="syncing"> <el-menu-item index="/contract-full">合约全景</el-menu-item>
同步数据
</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item> <el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item> <el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting"> <el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
数据重置 数据重置
@@ -120,12 +101,11 @@ async function handleSync() {
:active-text-color="menuColors.active" :active-text-color="menuColors.active"
@select="closeDrawer" @select="closeDrawer"
> >
<el-menu-item index="/scores">打分列表</el-menu-item> <el-menu-item index="/scores">品种打分</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item> <el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleSync" :disabled="syncing"> <el-menu-item index="/contract-full">合约全景</el-menu-item>
同步数据
</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item> <el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item> <el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>

View File

@@ -3,3 +3,18 @@ import client from './client'
export function resetAllData() { export function resetAllData() {
return client.post('/admin/reset-data').then((r) => r.data) return client.post('/admin/reset-data').then((r) => r.data)
} }
export interface LLMConfig {
base_url: string
api_key: string
model: string
has_api_key: boolean
}
export function getLLMConfig() {
return client.get<LLMConfig>('/admin/llm-config').then((r) => r.data)
}
export function saveLLMConfig(cfg: { base_url: string; api_key: string; model: string }) {
return client.put('/admin/llm-config', cfg).then((r) => r.data)
}

View File

@@ -0,0 +1,46 @@
import client from './client'
export interface DailyDirection {
id: string
symbol: string
trade_date: string
target_date: string
direction: string
confidence: number
support: string // JSON string of number[]
resistance: string // JSON string of number[]
reasoning: string
risk_note: string
created_at: string
}
export interface DailyDirectionRunRequest {
trade_date?: string
symbols?: string[]
}
export interface DailyDirectionRunResult {
symbol: string
direction: string
confidence: number
error?: string
}
export interface DailyDirectionRunResponse {
trade_date: string
results: DailyDirectionRunResult[]
errors?: DailyDirectionRunResult[]
}
export function runDailyDirection(req?: DailyDirectionRunRequest) {
return client.post<DailyDirectionRunResponse>('/ai/daily-direction', req ?? {}, { timeout: 300_000 }).then((r) => r.data)
}
export function listDailyDirections(params?: {
symbol?: string
start?: string
end?: string
limit?: number
}) {
return client.get<DailyDirection[]>('/ai/daily-direction', { params }).then((r) => r.data)
}

View File

@@ -27,7 +27,58 @@ export interface ActiveContract {
} }
export function runPipeline(req: RunRequest) { export function runPipeline(req: RunRequest) {
return client.post<RunResponse>('/run', req).then((r) => r.data) return client.post<RunResponse>('/run', req, { timeout: 60_000 }).then((r) => r.data)
}
export interface RunRangeRequest {
symbol: string
start_date: string
end_date: string
}
export interface RunRangeResult {
trade_date: string
close: number
composite: number
signal: string
}
export interface RunRangeResponse {
ts_code: string
start_date: string
end_date: string
scored: number
skipped: number
warnings: string[]
results: RunRangeResult[]
}
export interface RunFullRequest {
ts_code: string
}
export interface RunFullResult {
trade_date: string
close: number
composite: number
signal: string
}
export interface RunFullResponse {
ts_code: string
total_days: number
scored_count: number
skipped_count: number
warnings: string[]
results: RunFullResult[]
}
export function runRange(req: RunRangeRequest) {
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
}
export function runFull(req: RunFullRequest) {
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
} }
export function runBatch() { export function runBatch() {

View File

@@ -17,34 +17,50 @@ export interface ShortDetail {
export interface MediumDetail { export interface MediumDetail {
price_return_pct: number price_return_pct: number
price_signal: number price_signal: number
long_up_days: number accumulation_days: number
long_down_days: number distribution_days: number
covering_days: number
liquidation_days: number
fund_signal: number fund_signal: number
window: number window: number
} }
export interface LongDetail { export interface LongDetail {
avg_oi: number oi_now: number
oi_before: number oi_before: number
change_pct: number oi_change_pct: number
oi_score: number oi_score: number
price_score: number price_score: number
price_return_30d_pct: number price_return_30d_pct: number
price_before_30d: number price_before_30d: number
avg_oi_30d: number
window: number window: number
} }
export interface VolatilityDetail { export interface VolatilityDetail {
daily_vol_pct: number daily_vol_pct: number
atr_pct: number atr_pct: number
vol_risk: number
vol_penalty: number vol_penalty: number
} }
export interface AdaptiveWeights {
trend_strength: number
trend_factor: number
w_short: number
w_medium: number
w_long: number
}
export interface ScoreDetail { export interface ScoreDetail {
short_details?: ShortDetail[] short_details?: ShortDetail[]
medium_detail?: MediumDetail medium_detail?: MediumDetail
long_detail?: LongDetail long_detail?: LongDetail
volatility?: VolatilityDetail volatility?: VolatilityDetail
adaptive_weights?: AdaptiveWeights
vol_penalty?: number
composite_delta?: number | null
composite_delta_5d?: number | null
} }
export interface Score { export interface Score {

View File

@@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
const props = defineProps<{ data: Candle[] }>() const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
const theme = useThemeStore() const theme = useThemeStore()
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -28,28 +28,25 @@ function render() {
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high]) const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi) const oi = props.data.map((c) => c.oi)
chart.setOption( const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
{ const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, const hasScores = props.scores && props.scores.length > 0
legend: { data: ['K 线', '持仓量'], top: 0 }, const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
grid: [ const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' }, const grids: any[] = [
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' }, { left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
], { left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
xAxis: [ ]
const xAxes: any[] = [
{ type: 'category', data: dates, scale: true, boundaryGap: false }, { type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } }, { type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
], ]
yAxis: [ const yAxes: any[] = [
{ scale: true, splitArea: { show: true } }, { scale: true, splitArea: { show: true }, name: '价格', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
{ gridIndex: 1, scale: true, splitNumber: 3 }, { gridIndex: 1, scale: true, splitNumber: 3, name: '持仓', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
], ]
dataZoom: [ const series: any[] = [
{ type: 'inside', xAxisIndex: [0, 1] },
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 },
],
series: [
{ {
name: 'K 线', name: 'K 线',
type: 'candlestick', type: 'candlestick',
@@ -72,7 +69,68 @@ function render() {
lineStyle: { color: '#5470c6' }, lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' }, areaStyle: { opacity: 0.15, color: '#5470c6' },
}, },
]
if (hasScores) {
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2, name: '综合分', nameLocation: 'end', nameTextStyle: { fontSize: 11 } })
series.push({
name: '综合分',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: compositeData,
barWidth: '60%',
itemStyle: {
color: (params: any) => {
const val = params.value as number | null
if (val == null) return 'transparent'
if (val >= 80) return '#ec3a3a'
if (val >= 50) return '#f89898'
if (val >= 40) return '#89d6c7'
return '#26a69a'
},
},
})
}
chart.setOption(
{
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: (params: any) => {
if (!Array.isArray(params)) return ''
const date = params[0]?.axisValue || ''
let html = `<strong>${date}</strong><br/>`
for (const p of params) {
if (p.seriesName === 'K 线') {
const ohlc = p.data as number[]
const labels = ['开盘', '收盘', '最低', '最高']
html += labels.map((n, i) => `${p.marker} ${n}: ${ohlc[i] ?? '-'}`).join('<br/>') + '<br/>'
continue
}
let name = p.seriesName
let val: string
if (name === '持仓量') val = (p.data as number)?.toLocaleString() ?? '-'
else if (name === '综合分') val = (p.data as number)?.toFixed(1) ?? '-'
else val = p.data ?? '-'
html += `${p.marker} ${name}: ${val}<br/>`
}
return html
},
},
legend: { data: legendData, top: 0 },
grid: grids,
xAxis: xAxes,
yAxis: yAxes,
dataZoom: [
{ type: 'inside', xAxisIndex: xAxisIndices },
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
], ],
series,
}, },
true, true,
) )

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { getScore, type Score } from '@/api/scores' import { getScore, type Score } from '@/api/scores'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
@@ -12,6 +13,43 @@ const emit = defineEmits<{ (e: 'close'): void }>()
const score = ref<Score | null>(null) const score = ref<Score | null>(null)
const loading = ref(false) const loading = ref(false)
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let es: EventSource | null = null
function closeAI() {
es?.close()
es = null
aiLoading.value = false
}
async function askAI() {
if (!score.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const url = `/api/ai/analyze?ts_code=${encodeURIComponent(score.value.ts_code)}&trade_date=${encodeURIComponent(score.value.trade_date)}`
es = new EventSource(url)
es.addEventListener('token', (e) => {
aiContent.value += e.data
})
es.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
es.addEventListener('done', () => {
closeAI()
})
es.onerror = () => {
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
closeAI()
}
}
const visible = computed({ const visible = computed({
get: () => props.scoreId !== null, get: () => props.scoreId !== null,
set: (v) => { set: (v) => {
@@ -22,6 +60,9 @@ const visible = computed({
watch( watch(
() => props.scoreId, () => props.scoreId,
async (id) => { async (id) => {
closeAI()
aiContent.value = ''
aiError.value = ''
if (id === null) { if (id === null) {
score.value = null score.value = null
return return
@@ -59,7 +100,7 @@ const quadrantLabel = (q: string) => {
</script> </script>
<template> <template>
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '680px'" destroy-on-close> <el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" destroy-on-close>
<div v-loading="loading" v-if="score"> <div v-loading="loading" v-if="score">
<el-descriptions :column="isMobile ? 1 : 2" border> <el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item> <el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
@@ -73,11 +114,21 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="信号"> <el-descriptions-item label="信号">
<el-tag>{{ score.signal }}</el-tag> <el-tag>{{ score.signal }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="短期(7d × 0.4)">{{ score.short_term.toFixed(2) }}</el-descriptions-item> <el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="中期(15d × 0.35)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item> <el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="长期(30d × 0.25)" :span="isMobile ? 1 : 2"> <el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
{{ score.long_term.toFixed(2) }} {{ score.long_term.toFixed(2) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta != null" label="Δ1d">
<span :style="{ color: score.detail.composite_delta >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta >= 0 ? '+' : '' }}{{ score.detail.composite_delta.toFixed(1) }}
</span>
</el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta_5d != null" label="Δ5d">
<span :style="{ color: score.detail.composite_delta_5d >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta_5d >= 0 ? '+' : '' }}{{ score.detail.composite_delta_5d.toFixed(1) }}
</span>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4 class="section">短期 7 日逐日打分</h4> <h4 class="section">短期 7 日逐日打分</h4>
@@ -119,7 +170,7 @@ const quadrantLabel = (q: string) => {
</el-table> </el-table>
</div> </div>
<h4 class="section">中期(15d)细节</h4> <h4 class="section">中期(15d)资金意愿</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail"> <el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
<el-descriptions-item label="价格收益率"> <el-descriptions-item label="价格收益率">
{{ score.detail.medium_detail.price_return_pct.toFixed(2) }}% {{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
@@ -127,19 +178,25 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="价格信号分"> <el-descriptions-item label="价格信号分">
{{ score.detail.medium_detail.price_signal.toFixed(2) }} {{ score.detail.medium_detail.price_signal.toFixed(2) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="增仓上涨"> <el-descriptions-item label="增仓上涨">
{{ score.detail.medium_detail.long_up_days }} {{ score.detail.medium_detail.accumulation_days }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="增仓下跌"> <el-descriptions-item label="增仓下跌">
{{ score.detail.medium_detail.long_down_days }} {{ score.detail.medium_detail.distribution_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓上涨">
{{ score.detail.medium_detail.covering_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓下跌">
{{ score.detail.medium_detail.liquidation_days }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2"> <el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2">
{{ score.detail.medium_detail.fund_signal.toFixed(1) }} {{ score.detail.medium_detail.fund_signal.toFixed(1) }}
<span class="formula-hint">(50 + (增仓涨 - 增仓跌)/{{ score.detail.medium_detail.window ?? 15 }} × 50)</span> <span class="formula-hint">(四象限加权合成)</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4 class="section">长期(30d)细节</h4> <h4 class="section">长期(30d)结构</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail"> <el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
<el-descriptions-item label="OI 趋势分"> <el-descriptions-item label="OI 趋势分">
{{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }} {{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
@@ -155,14 +212,14 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="30 日前收盘"> <el-descriptions-item label="30 日前收盘">
{{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }} {{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="30 日均持仓"> <el-descriptions-item label="当前 OI">
{{ score.detail.long_detail.avg_oi.toFixed(0) }} {{ score.detail.long_detail.oi_now?.toFixed(0) ?? '-' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="30 日前持仓"> <el-descriptions-item label="30 日前 OI">
{{ score.detail.long_detail.oi_before.toFixed(0) }} {{ score.detail.long_detail.oi_before?.toFixed(0) ?? '-' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2"> <el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2">
{{ score.detail.long_detail.change_pct.toFixed(2) }}% {{ score.detail.long_detail.oi_change_pct?.toFixed(2) ?? '-' }}%
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -174,10 +231,57 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="ATR%"> <el-descriptions-item label="ATR%">
{{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}% {{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}%
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="波动率惩罚系数" :span="isMobile ? 1 : 2"> <el-descriptions-item label="综合波动风险" v-if="score.detail.volatility.vol_risk !== undefined">
{{ (score.detail.volatility.vol_risk * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="惩罚系数">
{{ score.detail.volatility.vol_penalty.toFixed(3) }} {{ score.detail.volatility.vol_penalty.toFixed(3) }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<template v-if="score.detail?.adaptive_weights">
<h4 class="section">自适应权重</h4>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="趋势强度">
{{ score.detail.adaptive_weights.trend_strength.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="趋势因子">
{{ (score.detail.adaptive_weights.trend_factor * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="短期权重">
{{ (score.detail.adaptive_weights.w_short * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="中期权重">
{{ (score.detail.adaptive_weights.w_medium * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="长期权重" :span="isMobile ? 1 : 2">
{{ (score.detail.adaptive_weights.w_long * 100).toFixed(0) }}%
</el-descriptions-item>
</el-descriptions>
</template>
<el-divider />
<div class="ai-section">
<div v-if="!aiLoading && !aiContent && !aiError">
<el-button type="primary" :loading="aiLoading" @click="askAI">
🤖 AI 分析当前打分
</el-button>
</div>
<div v-if="aiLoading || aiContent || aiError" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="closeAI" v-if="aiLoading">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="marked(aiContent)"></div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading">
<el-icon class="is-loading"><span></span></el-icon> 正在分析...
</div>
</div>
</div>
</div>
</div> </div>
</el-drawer> </el-drawer>
</template> </template>
@@ -197,4 +301,59 @@ const quadrantLabel = (q: string) => {
font-size: 12px; font-size: 12px;
margin-left: 6px; margin-left: 6px;
} }
.ai-section {
margin-top: 4px;
}
.ai-card {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-top: 8px;
overflow: hidden;
}
.ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-weight: 600;
}
.ai-body {
padding: 12px;
}
.ai-text {
line-height: 1.7;
}
.ai-text :deep(p) {
margin: 8px 0;
}
.ai-text :deep(h1),
.ai-text :deep(h2),
.ai-text :deep(h3),
.ai-text :deep(h4) {
margin: 16px 0 8px;
}
.ai-text :deep(ul),
.ai-text :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.ai-text :deep(li) {
margin: 4px 0;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
</style>
<style>
/* AI Markdown 输出段落间距(非 scoped确保 v-html 生效) */
.ai-text p { margin: 3px 0; }
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
.ai-text li { margin: 1px 0; }
.ai-text strong { color: var(--el-color-primary, #409eff); }
</style> </style>

View File

@@ -25,11 +25,21 @@ const routes: RouteRecordRaw[] = [
name: 'chart', name: 'chart',
component: () => import('@/views/ChartView.vue'), component: () => import('@/views/ChartView.vue'),
}, },
{
path: '/contract-full',
name: 'contract-full',
component: () => import('@/views/ContractFullView.vue'),
},
{ {
path: '/run', path: '/run',
name: 'run', name: 'run',
component: () => import('@/views/RunView.vue'), component: () => import('@/views/RunView.vue'),
}, },
{
path: '/daily-direction',
name: 'daily-direction',
component: () => import('@/views/DailyDirectionView.vue'),
},
{ {
path: '/admin/users', path: '/admin/users',
name: 'admin-users', name: 'admin-users',

View File

@@ -8,6 +8,7 @@ import {
updateUser, updateUser,
type AdminUser, type AdminUser,
} from '@/api/users' } from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -27,6 +28,52 @@ const resetDialog = reactive({
password: '', password: '',
}) })
// LLM 配置
const llmCfg = reactive({
base_url: 'https://api.deepseek.com/v1',
api_key: '',
model: 'deepseek-chat',
has_api_key: false,
saving: false,
loading: false,
})
async function loadLLMConfig() {
llmCfg.loading = true
try {
const cfg = await getLLMConfig()
llmCfg.base_url = cfg.base_url
llmCfg.api_key = cfg.api_key || ''
llmCfg.model = cfg.model
llmCfg.has_api_key = cfg.has_api_key
} catch {
// ignore
} finally {
llmCfg.loading = false
}
}
async function submitLLMConfig() {
if (!llmCfg.base_url.trim()) {
ElMessage.warning('API 地址不能为空')
return
}
llmCfg.saving = true
try {
await saveLLMConfig({
base_url: llmCfg.base_url.trim(),
api_key: llmCfg.api_key.trim(),
model: llmCfg.model.trim(),
})
ElMessage.success('LLM 配置已保存')
await loadLLMConfig()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '保存失败')
} finally {
llmCfg.saving = false
}
}
async function reload() { async function reload() {
loading.value = true loading.value = true
try { try {
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
await reload() await reload()
} }
onMounted(reload) onMounted(() => {
reload()
loadLLMConfig()
})
</script> </script>
<template> <template>
@@ -145,6 +195,38 @@ onMounted(reload)
</el-table> </el-table>
</div> </div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>🤖 LLM 配置</span>
<el-tag :type="llmCfg.has_api_key ? 'success' : 'warning'" size="small">
{{ llmCfg.has_api_key ? '已配置' : '未配置' }}
</el-tag>
</div>
</template>
<el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
<el-form-item label="API 地址">
<el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="llmCfg.api_key"
type="password"
show-password
placeholder="sk-..."
/>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="llmCfg.model" placeholder="deepseek-chat" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="llmCfg.saving" @click="submitLLMConfig">
保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px"> <el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="用户名"> <el-form-item label="用户名">
@@ -197,6 +279,11 @@ onMounted(reload)
align-items: center; align-items: center;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
} }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper { .table-wrapper {
background: var(--el-bg-color); background: var(--el-bg-color);
border-radius: 4px; border-radius: 4px;

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles'
import { listScores, type Score } from '@/api/scores'
import { runFull, type RunFullResponse } from '@/api/run'
import KLineChart from '@/components/KLineChart.vue'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
ts_code: '',
range: [],
})
const contracts = ref<string[]>([])
const candles = ref<Candle[]>([])
const scores = ref<Score[]>([])
const loading = ref(false)
const fullLoading = ref(false)
const fullResult = ref<RunFullResponse | null>(null)
async function reload() {
if (!filter.ts_code) {
ElMessage.warning('请输入或选择合约')
return
}
loading.value = true
try {
const [start, end] = filter.range || []
const [candleData, scoreData] = await Promise.all([
listCandles(filter.ts_code, start, end),
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
])
candles.value = candleData
scores.value = scoreData
// 如果数据库里没有该合约数据,自动拉取并打分
if (candleData.length === 0) {
await doFetchAndScore(true)
}
} finally {
loading.value = false
}
}
async function doFetchAndScore(skipConfirm: boolean) {
if (!filter.ts_code) {
ElMessage.warning('请输入或选择合约')
return
}
if (!skipConfirm) {
try {
await ElMessageBox.confirm(
`即将拉取 ${filter.ts_code} 的全部历史数据并逐日打分,这可能需要一些时间。`,
'拉取并打分',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
}
fullLoading.value = true
fullResult.value = null
try {
const resp = await runFull({ ts_code: filter.ts_code })
fullResult.value = resp
// 用后端返回的完整代码(含交易所后缀)更新输入框
filter.ts_code = resp.ts_code
ElMessage.success(`完成: ${resp.scored_count} 天已打分, ${resp.skipped_count} 天跳过`)
await loadData()
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
fullLoading.value = false
}
}
async function handleFetchAndScore() {
await doFetchAndScore(false)
}
async function loadData() {
if (!filter.ts_code) return
const [start, end] = filter.range || []
const [candleData, scoreData] = await Promise.all([
listCandles(filter.ts_code, start, end),
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
])
candles.value = candleData
scores.value = scoreData
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
if (contracts.value.length > 0) {
filter.ts_code = contracts.value[0]
await reload()
}
})
</script>
<template>
<div class="page">
<el-card shadow="never" class="filter-card">
<el-form :inline="!isMobile">
<el-form-item label="合约">
<div style="display: flex; gap: 8px;">
<el-input
v-model="filter.ts_code"
placeholder="输入合约代码如 FG2509"
clearable
:style="{ width: isMobile ? '100%' : '200px' }"
/>
<el-select
v-model="filter.ts_code"
placeholder="已存合约"
clearable
style="width: 120px"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</div>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="filter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
:style="{ width: isMobile ? '100%' : 'auto' }"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
<el-button type="warning" :loading="fullLoading" @click="handleFetchAndScore">
拉取并打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="fullResult" shadow="never" class="result-card">
<el-descriptions :column="isMobile ? 2 : 4" border>
<el-descriptions-item label="合约">{{ fullResult.ts_code }}</el-descriptions-item>
<el-descriptions-item label="总天数">{{ fullResult.total_days }}</el-descriptions-item>
<el-descriptions-item label="已打分">{{ fullResult.scored_count }}</el-descriptions-item>
<el-descriptions-item label="跳过">{{ fullResult.skipped_count }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="chart-card" v-loading="loading">
<KLineChart
:data="candles"
:scores="scores.map((s) => ({ trade_date: s.trade_date, composite: s.composite }))"
/>
</el-card>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.result-card :deep(.el-card__body) {
padding: 12px 16px;
}
.chart-card :deep(.el-card__body) {
padding: 8px;
}
</style>

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
listDailyDirections,
runDailyDirection,
type DailyDirection,
type DailyDirectionRunResponse,
} from '@/api/daily'
import { runBatch } from '@/api/run'
const items = ref<DailyDirection[]>([])
const loading = ref(false)
const running = ref(false)
const runStep = ref('')
const runResult = ref<DailyDirectionRunResponse | null>(null)
const selectedRow = ref<DailyDirection | null>(null)
const drawerOpen = ref(false)
const symbolNames: Record<string, string> = {
FG: '玻璃', SA: '纯碱', RB: '螺纹钢', MA: '甲醇', CF: '棉花', M: '豆粕',
}
const directionLabel = (d: string) => {
switch (d) {
case 'bullish': return '看多'
case 'bearish': return '看空'
case 'neutral': return '震荡'
default: return d
}
}
const directionType = (d: string): '' | 'success' | 'danger' | 'warning' => {
switch (d) {
case 'bullish': return 'success'
case 'bearish': return 'danger'
case 'neutral': return 'warning'
default: return ''
}
}
const runSummary = computed(() => {
if (!runResult.value) return ''
const parts: string[] = []
for (const r of runResult.value.results ?? []) {
parts.push(`${symbolNames[r.symbol] ?? r.symbol}: ${directionLabel(r.direction)}`)
}
return parts.join(' | ')
})
const drawerTitle = computed(() => {
if (!selectedRow.value) return ''
const name = symbolNames[selectedRow.value.symbol] ?? selectedRow.value.symbol
return `${name}(${selectedRow.value.symbol}) · ${selectedRow.value.trade_date}`
})
async function fetchList() {
loading.value = true
try {
items.value = await listDailyDirections({ limit: 50 })
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function handleRun() {
running.value = true
runResult.value = null
try {
runStep.value = '正在拉取数据…'
await runBatch()
runStep.value = '正在 AI 分析…'
runResult.value = await runDailyDirection()
const ok = runResult.value?.results?.length ?? 0
const fail = runResult.value?.errors?.length ?? 0
if (fail > 0) {
ElMessage.warning(`分析完成:成功 ${ok} 个,失败 ${fail}`)
} else {
ElMessage.success(`已完成 ${ok} 个品种的方向分析`)
}
await fetchList()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '分析失败')
} finally {
running.value = false
}
}
function parseLevels(json: string): number[] {
try {
return JSON.parse(json) as number[]
} catch {
return []
}
}
function levelAt(json: string, i: number): string {
const arr = parseLevels(json)
return arr[i] != null ? String(arr[i]) : '-'
}
function openDrawer(row: DailyDirection) {
selectedRow.value = row
drawerOpen.value = true
}
onMounted(fetchList)
</script>
<template>
<div class="daily-direction">
<div class="toolbar">
<h2>日内方向分析</h2>
<el-button type="primary" :loading="running" @click="handleRun">
{{ running ? runStep : '执行分析' }}
</el-button>
</div>
<el-alert
v-if="runResult"
:title="`${runResult.trade_date} 分析结果`"
:description="runSummary"
type="info"
show-icon
closable
style="margin-bottom: 16px"
/>
<el-table :data="items" stripe v-loading="loading" empty-text="暂无数据,请先执行分析" highlight-current-row @row-click="openDrawer">
<el-table-column prop="symbol" label="品种" width="80">
<template #default="{ row }">
{{ symbolNames[row.symbol] ?? row.symbol }}
</template>
</el-table-column>
<el-table-column prop="trade_date" label="分析日" width="110" />
<el-table-column prop="target_date" label="目标日" width="110" />
<el-table-column prop="direction" label="方向" width="80">
<template #default="{ row }">
<el-tag :type="directionType(row.direction)" size="small">
{{ directionLabel(row.direction) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.confidence >= 70 ? '#67c23a' : row.confidence >= 50 ? '#e6a23c' : '#f56c6c', fontWeight: 'bold' }">
{{ row.confidence }}%
</span>
</template>
</el-table-column>
<el-table-column label="支撑位一" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.support, 0) }}</template>
</el-table-column>
<el-table-column label="支撑位二" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.support, 1) }}</template>
</el-table-column>
<el-table-column label="阻力位一" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.resistance, 0) }}</template>
</el-table-column>
<el-table-column label="阻力位二" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.resistance, 1) }}</template>
</el-table-column>
<el-table-column label="明细" width="80" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click.stop="openDrawer(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="drawerOpen" :title="drawerTitle" size="480px" direction="rtl">
<template v-if="selectedRow">
<div class="drawer-section">
<div class="drawer-label">方向判断</div>
<el-tag :type="directionType(selectedRow.direction)" size="default">
{{ directionLabel(selectedRow.direction) }}
</el-tag>
<span class="drawer-confidence" :style="{ color: selectedRow.confidence >= 70 ? '#67c23a' : selectedRow.confidence >= 50 ? '#e6a23c' : '#f56c6c' }">
置信度 {{ selectedRow.confidence }}%
</span>
</div>
<div class="drawer-section">
<div class="drawer-label">支撑位</div>
<div class="level-rows">
<div v-for="(v, i) in parseLevels(selectedRow.support)" :key="'ds'+i" class="level-row sup">
<span class="level-tag">支撑{{ i + 1 }}</span>
<span class="level-val">{{ v }}</span>
</div>
<div v-if="!parseLevels(selectedRow.support).length" class="level-row">-</div>
</div>
</div>
<div class="drawer-section">
<div class="drawer-label">阻力位</div>
<div class="level-rows">
<div v-for="(v, i) in parseLevels(selectedRow.resistance)" :key="'dr'+i" class="level-row res">
<span class="level-tag">阻力{{ i + 1 }}</span>
<span class="level-val">{{ v }}</span>
</div>
<div v-if="!parseLevels(selectedRow.resistance).length" class="level-row">-</div>
</div>
</div>
<div class="drawer-section">
<div class="drawer-label">分析逻辑</div>
<div class="drawer-text">{{ selectedRow.reasoning || '-' }}</div>
</div>
<div class="drawer-section">
<div class="drawer-label">风险提示</div>
<div class="drawer-text risk">{{ selectedRow.risk_note || '-' }}</div>
</div>
<div class="drawer-section meta">
<span>分析日期{{ selectedRow.trade_date }}</span>
<span>目标交易日{{ selectedRow.target_date }}</span>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.daily-direction {
max-width: 1400px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.toolbar h2 {
margin: 0;
font-size: 18px;
}
.drawer-section {
margin-bottom: 20px;
}
.drawer-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.drawer-confidence {
margin-left: 12px;
font-size: 16px;
font-weight: bold;
}
.drawer-text {
font-size: 15px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--el-text-color-primary);
}
.drawer-text.risk {
color: var(--el-color-warning);
}
.level-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.level-row {
display: flex;
align-items: center;
gap: 12px;
}
.level-tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 4px;
font-weight: 600;
min-width: 54px;
text-align: center;
}
.level-row.sup .level-tag {
background: #ecf5ff;
color: #409eff;
}
.level-row.res .level-tag {
background: #fef0f0;
color: #f56c6c;
}
.level-val {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.drawer-section.meta {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-light);
display: flex;
gap: 24px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -3,9 +3,11 @@ import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
runPipeline, runPipeline,
runRange,
getActiveContract, getActiveContract,
type ActiveContract, type ActiveContract,
type RunResponse, type RunResponse,
type RunRangeResponse,
} from '@/api/run' } from '@/api/run'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
@@ -14,6 +16,8 @@ const { isMobile } = useMobile()
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M'] const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
const mode = ref<'single' | 'range'>('single')
const form = reactive<{ const form = reactive<{
symbol: string symbol: string
trade_date: string trade_date: string
@@ -22,20 +26,32 @@ const form = reactive<{
trade_date: '', trade_date: '',
}) })
const range = reactive<{
dates: [string, string] | []
}>({
dates: [],
})
const active = ref<ActiveContract | null>(null) const active = ref<ActiveContract | null>(null)
const activeLoading = ref(false) const activeLoading = ref(false)
const loading = ref(false) const loading = ref(false)
const result = ref<RunResponse | null>(null) const result = ref<RunResponse | null>(null)
const rangeResult = ref<RunRangeResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null) const resultRef = ref<HTMLElement | null>(null)
async function loadActive() { async function loadActive() {
activeLoading.value = true activeLoading.value = true
try { try {
active.value = await getActiveContract(form.symbol) active.value = await getActiveContract(form.symbol)
// 切换品种后,如果原日期落在新合约的可选范围之外,清空它
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) { if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
form.trade_date = '' form.trade_date = ''
} }
if (Array.isArray(range.dates) && range.dates.length === 2) {
const [s, e] = range.dates
if (!isDateAllowed(toDate(s)) || !isDateAllowed(toDate(e))) {
range.dates = []
}
}
} catch (err: any) { } catch (err: any) {
active.value = null active.value = null
ElMessage.error(err?.response?.data?.error || '加载主力合约失败') ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
@@ -45,17 +61,15 @@ async function loadActive() {
} }
function toDate(s: string) { function toDate(s: string) {
// s 形如 'YYYY-MM-DD'
const [y, m, d] = s.split('-').map(Number) const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d) return new Date(y, m - 1, d)
} }
function isDateAllowed(d: Date): boolean { function isDateAllowed(d: Date): boolean {
if (!active.value) return true if (!active.value) return true
const min = toDate(active.value.min_date).getTime()
const max = toDate(active.value.max_date).getTime() const max = toDate(active.value.max_date).getTime()
const t = d.getTime() const t = d.getTime()
return t >= min && t <= max return t <= max
} }
function disabledDate(d: Date) { function disabledDate(d: Date) {
@@ -69,12 +83,29 @@ async function submit() {
} }
loading.value = true loading.value = true
result.value = null result.value = null
rangeResult.value = null
try { try {
if (mode.value === 'single') {
const req: { symbol: string; trade_date?: string } = { symbol: form.symbol } const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '') if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
const resp = await runPipeline(req) const resp = await runPipeline(req)
result.value = resp result.value = resp
ElMessage.success('打分完成') ElMessage.success('打分完成')
} else {
if (!Array.isArray(range.dates) || range.dates.length !== 2) {
ElMessage.warning('请选择日期区间')
loading.value = false
return
}
const [start, end] = range.dates
const resp = await runRange({
symbol: form.symbol,
start_date: start.replace(/-/g, ''),
end_date: end.replace(/-/g, ''),
})
rangeResult.value = resp
ElMessage.success(`区间打分完成,成功 ${resp.scored}`)
}
await nextTick() await nextTick()
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} catch (err: any) { } catch (err: any) {
@@ -104,21 +135,18 @@ onMounted(loadActive)
<span>手动打分</span> <span>手动打分</span>
</template> </template>
<el-form :model="form" label-width="100px" style="max-width: 480px"> <el-form :model="form" label-width="100px" style="max-width: 480px">
<el-form-item label="模式">
<el-radio-group v-model="mode">
<el-radio-button label="single">单日打分</el-radio-button>
<el-radio-button label="range">区间打分</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="品种"> <el-form-item label="品种">
<el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%"> <el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%">
<el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" /> <el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="主力合约"> <el-form-item v-if="mode === 'single'" label="打分日期">
<span v-if="active">
{{ parseTsCode(active.ts_code).contract }}
<el-text type="info" size="small" style="margin-left: 8px">
({{ active.ts_code }})
</el-text>
</span>
<el-text v-else type="info">加载中</el-text>
</el-form-item>
<el-form-item label="打分日期">
<el-date-picker <el-date-picker
v-model="form.trade_date" v-model="form.trade_date"
type="date" type="date"
@@ -129,9 +157,23 @@ onMounted(loadActive)
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item v-else label="日期区间">
<el-date-picker
v-model="range.dates"
type="daterange"
:placeholder="active ? `${active.min_date} ~ ${active.max_date}` : '加载中…'"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
:disabled="!active"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 100%"
/>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" :disabled="!active" @click="submit"> <el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
执行打分 {{ mode === 'single' ? '执行打分' : '批量打分' }}
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -160,6 +202,41 @@ onMounted(loadActive)
</el-descriptions> </el-descriptions>
</el-card> </el-card>
</div> </div>
<div v-if="rangeResult" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header>
<span>区间打分结果</span>
</template>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="合约">{{ parseTsCode(rangeResult.ts_code).symbol }}</el-descriptions-item>
<el-descriptions-item label="区间">{{ rangeResult.start_date }} ~ {{ rangeResult.end_date }}</el-descriptions-item>
<el-descriptions-item label="成功">{{ rangeResult.scored }} </el-descriptions-item>
<el-descriptions-item label="跳过">{{ rangeResult.skipped }} </el-descriptions-item>
</el-descriptions>
<el-alert
v-if="rangeResult.warnings.length > 0"
:title="`警告 (${rangeResult.warnings.length} 条)`"
type="warning"
:closable="false"
style="margin-top: 12px"
>
<div style="max-height: 120px; overflow-y: auto">
<div v-for="(w, i) in rangeResult.warnings" :key="i" style="font-size: 12px">{{ w }}</div>
</div>
</el-alert>
<el-table :data="rangeResult.results" stripe style="margin-top: 16px" max-height="400">
<el-table-column prop="trade_date" label="日期" width="110" />
<el-table-column prop="close" label="收盘" width="90" />
<el-table-column prop="composite" label="综合" width="80" />
<el-table-column label="信号">
<template #default="{ row }">
<el-tag :type="signalTagType(row.signal)" size="small">{{ row.signal }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div> </div>
</template> </template>
@@ -169,4 +246,7 @@ onMounted(loadActive)
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.result-card {
margin-top: 8px;
}
</style> </style>

View File

@@ -1,50 +1,64 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { nextTick, onMounted, reactive, ref, watch, computed } from 'vue'
import { listContracts, listScores, type Score } from '@/api/scores' import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import { listScores, listContracts, type Score } from '@/api/scores'
import { runPipeline, type RunResponse } from '@/api/run'
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue' import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile() const { isMobile } = useMobile()
const filter = reactive<{ const EXCHANGES = [
ts_code?: string { code: 'ZCE', name: '郑商所' },
range: [string, string] | [] { code: 'SHF', name: '上期所' },
signal?: string { code: 'DCE', name: '大商所' },
limit: number ]
}>({
ts_code: undefined, const SYMBOLS_BY_EXCHANGE: Record<string, string[]> = {
range: [], ZCE: ['FG', 'SA', 'MA', 'CF'],
signal: undefined, SHF: ['RB'],
limit: 200, DCE: ['M'],
}
// 品种打分
const selectedExchange = ref('')
const selectedSymbol = ref('')
const scoring = ref(false)
const scoreResult = ref<RunResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null)
const availableSymbols = computed(() => {
if (!selectedExchange.value) return []
return SYMBOLS_BY_EXCHANGE[selectedExchange.value] || []
}) })
const contracts = ref<string[]>([]) watch(selectedExchange, () => {
const rows = ref<Score[]>([]) selectedSymbol.value = ''
const loading = ref(false) })
const drawerScoreId = ref<number | null>(null)
async function reload(silent = false) { async function handleScore() {
if (!silent) loading.value = true if (!selectedSymbol.value) {
ElMessage.warning('请选择品种')
return
}
scoring.value = true
scoreResult.value = null
try { try {
const [start, end] = filter.range || [] const resp = await runPipeline({ symbol: selectedSymbol.value })
rows.value = await listScores({ scoreResult.value = resp
ts_code: filter.ts_code, ElMessage.success('打分完成')
start: start || undefined, await nextTick()
end: end || undefined, resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
signal: filter.signal, } catch (err: any) {
limit: filter.limit, const msg = err?.response?.data?.error || err.message || '请求失败'
}) ElMessage.error(msg)
} finally { } finally {
if (!silent) loading.value = false scoring.value = false
} }
} }
function toggleSignal(s: string) {
filter.signal = filter.signal === s ? undefined : s
reload(true)
}
function signalTagType(s: string) { function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success' if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return '' if (s.includes('偏多')) return ''
@@ -61,19 +75,202 @@ function signalIcon(s: string) {
return '' return ''
} }
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let aiES: EventSource | null = null
function closeAI() {
aiES?.close()
aiES = null
aiLoading.value = false
}
async function askAI() {
if (!scoreResult.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const ts = encodeURIComponent(scoreResult.value.ts_code)
const td = encodeURIComponent(scoreResult.value.trade_date)
aiES = new EventSource(`/api/ai/analyze?ts_code=${ts}&trade_date=${td}`)
aiES.addEventListener('token', (e) => { aiContent.value += e.data })
aiES.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
aiES.addEventListener('done', () => closeAI())
aiES.onerror = () => {
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
closeAI()
}
}
// 历史查询(折叠)
const showHistory = ref(false)
const historyFilter = reactive<{
ts_code?: string
range: [string, string] | []
signal?: string
limit: number
}>({
ts_code: undefined,
range: [],
signal: undefined,
limit: 50,
})
const contracts = ref<string[]>([])
const historyRows = ref<Score[]>([])
const historyLoading = ref(false)
const drawerScoreId = ref<number | null>(null)
async function reloadHistory(silent = false) {
if (!silent) historyLoading.value = true
try {
const [start, end] = historyFilter.range || []
historyRows.value = await listScores({
ts_code: historyFilter.ts_code,
start: start || undefined,
end: end || undefined,
signal: historyFilter.signal,
limit: historyFilter.limit,
})
} finally {
if (!silent) historyLoading.value = false
}
}
function toggleSignal(s: string) {
historyFilter.signal = historyFilter.signal === s ? undefined : s
reloadHistory(true)
}
onMounted(async () => { onMounted(async () => {
contracts.value = await listContracts().catch(() => []) contracts.value = await listContracts().catch(() => [])
await reload()
}) })
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<el-card shadow="never" class="filter-card"> <!-- 品种打分 -->
<el-card shadow="never">
<template #header>
<span>品种打分</span>
</template>
<el-form :inline="!isMobile">
<el-form-item label="交易所">
<el-select
v-model="selectedExchange"
placeholder="选择交易所"
clearable
:style="{ width: isMobile ? '100%' : '160px' }"
>
<el-option
v-for="ex in EXCHANGES"
:key="ex.code"
:label="ex.name"
:value="ex.code"
/>
</el-select>
</el-form-item>
<el-form-item label="品种">
<el-select
v-model="selectedSymbol"
placeholder="选择品种"
:disabled="!selectedExchange"
:style="{ width: isMobile ? '100%' : '120px' }"
>
<el-option
v-for="s in availableSymbols"
:key="s"
:label="s"
:value="s"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="scoring"
:disabled="!selectedSymbol"
@click="handleScore"
>
打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 打分结果 -->
<div v-if="scoreResult" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header>
<span>打分结果</span>
</template>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">
{{ parseTsCode(scoreResult.ts_code).symbol }}
</el-descriptions-item>
<el-descriptions-item label="合约">
{{ parseTsCode(scoreResult.ts_code).contract }}
</el-descriptions-item>
<el-descriptions-item label="日期">{{ scoreResult.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ scoreResult.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ scoreResult.oi }}</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ scoreResult.short_term }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ scoreResult.medium_term }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)">{{ scoreResult.long_term }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ scoreResult.composite }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
<el-tag :type="signalTagType(scoreResult.signal)">
{{ signalIcon(scoreResult.signal) }} {{ scoreResult.signal }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="ai-section">
<el-button
v-if="!aiLoading && !aiContent && !aiError"
type="primary"
:loading="aiLoading"
@click="askAI"
>
🤖 AI 分析当前打分
</el-button>
<div v-if="aiLoading || aiContent || aiError" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button v-if="aiLoading" text size="small" @click="closeAI">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="marked(aiContent)" />
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading"> 正在分析...</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 历史查询折叠 -->
<el-card shadow="never">
<template #header>
<div class="history-header" @click="showHistory = !showHistory">
<span>历史打分查询</span>
<span>{{ showHistory ? '▲' : '▼' }}</span>
</div>
</template>
<div v-if="showHistory">
<el-form :inline="!isMobile"> <el-form :inline="!isMobile">
<el-form-item label="合约"> <el-form-item label="合约">
<el-select <el-select
v-model="filter.ts_code" v-model="historyFilter.ts_code"
placeholder="全部合约" placeholder="全部合约"
clearable clearable
filterable filterable
@@ -84,7 +281,7 @@ onMounted(async () => {
</el-form-item> </el-form-item>
<el-form-item label="日期"> <el-form-item label="日期">
<el-date-picker <el-date-picker
v-model="filter.range" v-model="historyFilter.range"
type="daterange" type="daterange"
value-format="YYYYMMDD" value-format="YYYYMMDD"
range-separator="" range-separator=""
@@ -94,33 +291,35 @@ onMounted(async () => {
/> />
</el-form-item> </el-form-item>
<el-form-item label="条数"> <el-form-item label="条数">
<el-input-number v-model="filter.limit" :min="10" :max="500" :step="50" /> <el-input-number v-model="historyFilter.limit" :min="10" :max="500" :step="50" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" style="width: 88px" @click="reload">查询</el-button> <el-button type="primary" :loading="historyLoading" style="width: 88px" @click="reloadHistory">
查询
</el-button>
</el-form-item> </el-form-item>
<el-form-item label="快捷" class="signal-item"> <el-form-item label="快捷" class="signal-item">
<el-button-group class="signal-group" :size="isMobile ? 'small' : 'default'"> <el-button-group class="signal-group" :size="isMobile ? 'small' : 'default'">
<el-button <el-button
:type="filter.signal === '强烈看多' ? 'success' : ''" :type="historyFilter.signal === '强烈看多' ? 'success' : ''"
@click="toggleSignal('强烈看多')" @click="toggleSignal('强烈看多')"
> >
强烈看多 强烈看多
</el-button> </el-button>
<el-button <el-button
:type="filter.signal === '偏多' ? 'primary' : ''" :type="historyFilter.signal === '偏多' ? 'primary' : ''"
@click="toggleSignal('偏多')" @click="toggleSignal('偏多')"
> >
偏多 偏多
</el-button> </el-button>
<el-button <el-button
:type="filter.signal === '偏空' ? 'warning' : ''" :type="historyFilter.signal === '偏空' ? 'warning' : ''"
@click="toggleSignal('偏空')" @click="toggleSignal('偏空')"
> >
偏空 偏空
</el-button> </el-button>
<el-button <el-button
:type="filter.signal === '强烈看空' ? 'danger' : ''" :type="historyFilter.signal === '强烈看空' ? 'danger' : ''"
@click="toggleSignal('强烈看空')" @click="toggleSignal('强烈看空')"
> >
强烈看空 强烈看空
@@ -128,10 +327,9 @@ onMounted(async () => {
</el-button-group> </el-button-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card>
<div class="table-wrapper" v-loading="loading"> <div class="table-wrapper" v-loading="historyLoading">
<el-table :data="rows" stripe class="score-table"> <el-table :data="historyRows" stripe class="score-table">
<el-table-column prop="trade_date" label="日期" width="100" /> <el-table-column prop="trade_date" label="日期" width="100" />
<el-table-column label="品种" width="80"> <el-table-column label="品种" width="80">
<template #default="{ row }"> <template #default="{ row }">
@@ -156,7 +354,9 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<el-table-column prop="signal" label="信号" min-width="160"> <el-table-column prop="signal" label="信号" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="signalTagType(row.signal)">{{ signalIcon(row.signal) }} {{ row.signal }}</el-tag> <el-tag :type="signalTagType(row.signal)">
{{ signalIcon(row.signal) }} {{ row.signal }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" fixed="right"> <el-table-column label="操作" width="80" fixed="right">
@@ -166,11 +366,10 @@ onMounted(async () => {
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
</div>
</el-card>
<ScoreDetailDrawer <ScoreDetailDrawer :score-id="drawerScoreId" @close="drawerScoreId = null" />
:score-id="drawerScoreId"
@close="drawerScoreId = null"
/>
</div> </div>
</template> </template>
@@ -180,6 +379,45 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.result-card {
margin-top: 4px;
}
.ai-section {
margin-top: 12px;
}
.ai-card {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-top: 8px;
overflow: hidden;
}
.ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-weight: 600;
}
.ai-body {
padding: 12px;
}
.ai-text {
line-height: 1.5;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.filter-card :deep(.el-card__body) { .filter-card :deep(.el-card__body) {
padding: 12px 16px; padding: 12px 16px;
} }
@@ -209,3 +447,12 @@ onMounted(async () => {
} }
} }
</style> </style>
<style>
/* AI Markdown 输出段落间距(非 scoped确保 v-html 生效) */
.ai-text p { margin: 3px 0; }
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
.ai-text li { margin: 1px 0; }
.ai-text strong { color: var(--el-color-primary, #409eff); }
</style>