feat(paper): V3 策略候选对比卡 + AI 个股分析注入虚拟持仓

- 模拟盘右列新增「策略对比」卡: 统计 vs 策略候选 (回测报告持久化摘要, 同口径标量),
  各按己方单位渲染 (候选小数 fmtPct / 模拟盘百分数), 不做数值换算
- api 客户端新增 backtestCandidates + BacktestCandidate 类型 + QK 键
- stock_analyzer: 模拟盘默认账户持有该标的时, 用户提示词注入虚拟持仓上下文
  (数量/成本/浮盈, 浮盈优先 raw_close 口径避免除权失真; 注入失败静默跳过)
- 测试: 注入/空仓/raw 口径 3 项 + 既有 46 项全过; 前端 build 通过
This commit is contained in:
shy3130
2026-09-24 18:13:11 +08:00
parent 28bd46df2a
commit 51d8083e89
5 changed files with 201 additions and 3 deletions
+44 -2
View File
@@ -195,6 +195,32 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线研究经验的技术
# 用户消息构建
# ================================================================
def _paper_position_part(pos: dict | None, close: float | None, raw_close: float | None) -> str:
"""虚拟持仓上下文 (V3): 模拟盘持有该标的时注入提示词, 让 AI 结合仓位给建议。
pos 为空 / 数量或成本无效时返回空串 (不注入"空仓"之类误导性表述)。
浮盈用 raw 口径: 持仓 avg_cost 是不复权 raw 成本, 与前复权 close 混算会在
除权后产生虚假盈亏, 故优先用 raw_close (缺列时退化用 close 并接受口径误差)。
"""
if not pos:
return ""
qty = pos.get("qty") or 0
avg_cost = pos.get("avg_cost") or 0
if qty <= 0 or avg_cost <= 0:
return ""
ref = raw_close if (raw_close is not None and raw_close > 0) else close
lines = [f"持仓数量: {qty} 股, 平均成本: {avg_cost:.3f} 元 (不复权)"]
if ref is not None and ref > 0:
pnl_pct = (ref / avg_cost - 1) * 100
lines.append(f"现价 {ref:.2f} 元, 浮动盈亏: {pnl_pct:+.2f}%")
return (
"\n以下是该标的在模拟盘 (虚拟账户) 的当前持仓:\n"
+ "\n".join(lines)
+ "\n请在结论中结合该虚拟持仓状态给出持有/减仓/加仓的可执行参考,"
"并明确这是基于模拟盘虚拟持仓的建议。\n"
)
def _build_user_prompt(
kline_tail: list[dict],
fins: dict[str, list[dict]],
@@ -203,8 +229,10 @@ def _build_user_prompt(
symbol: str,
focus: str,
asset_type: str = "stock",
paper_position: dict | None = None,
raw_close: float | None = None,
) -> str:
"""构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。
"""构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点 (+ 虚拟持仓)。
asset_type 用于区分无财务数据时的文案:指数/ETF 无财务是常态,不走 Free 文案。
"""
@@ -254,6 +282,9 @@ def _build_user_prompt(
focus_instruction = build_focus_instruction(focus, report_name="个股分析报告")
if focus_instruction:
parts.extend(["", focus_instruction])
paper_part = _paper_position_part(paper_position, close, raw_close)
if paper_part:
parts.extend(["", paper_part])
return "\n".join(parts)
@@ -325,9 +356,20 @@ async def analyze_stock_stream(
try:
from app.services.ai_provider import stream_ai_text
# 虚拟持仓上下文 (V3): 默认模拟盘账户持有该标的时注入 (任何异常静默跳过, 不影响分析)。
paper_position = None
try:
from app.strategy import paper as paper_trading
paper_position = paper_trading.load_positions(data_dir).get(symbol)
except Exception as e: # noqa: BLE001
logger.warning("读取模拟盘持仓失败 (跳过注入): %s", e)
# 浮盈口径用 raw: 持仓成本不复权, 与前复权 close 混算会在除权后失真
raw_close = float(df.tail(1)["raw_close"][0]) if "raw_close" in df.columns else None
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus,
asset_type=asset_type)
asset_type=asset_type, paper_position=paper_position,
raw_close=raw_close)
got_content = False
async for delta in stream_ai_text(
[
@@ -29,3 +29,45 @@ def test_user_prompt_etf_no_financials():
assert "ETF" in prompt
assert "Free 模式" not in prompt
assert "尚未同步财务报表" not in prompt
# ===== V3: 虚拟持仓注入 =====
def test_user_prompt_injects_paper_position():
"""模拟盘持有该标的: 注入持仓数量/成本/浮盈, 并要求 AI 给持仓建议。"""
from app.services.stock_analyzer import _paper_position_part, _build_user_prompt
part = _paper_position_part({"qty": 1000, "avg_cost": 10.0}, close=11.0, raw_close=11.0)
assert "1000 股" in part
assert "10.000" in part
assert "+10.00%" in part
assert "虚拟持仓" in part
prompt = _build_user_prompt(
kline_tail=[{"date": "2026-07-24", "close": 11.0}],
fins={"metrics": [], "income": []},
levels={}, close=11.0, symbol="600000.SH", focus="",
paper_position={"qty": 1000, "avg_cost": 10.0}, raw_close=11.0,
)
assert "模拟盘" in prompt and "持仓数量" in prompt
def test_paper_position_part_empty_without_position():
"""无持仓/零仓/无效成本: 返回空串, 不注入误导性「空仓」表述。"""
from app.services.stock_analyzer import _paper_position_part
assert _paper_position_part(None, 11.0, 11.0) == ""
assert _paper_position_part({"qty": 0, "avg_cost": 10.0}, 11.0, 11.0) == ""
assert _paper_position_part({"qty": 100, "avg_cost": 0}, 11.0, 11.0) == ""
def test_paper_position_part_prefers_raw_close():
"""除权后前复权 close 与 raw 成本混算会产生虚假盈亏: 必须优先 raw_close。
raw 成本 20 元, 除权后前复权 close=11、raw_close=21: 用 close 算出 -45% 是错的。
"""
from app.services.stock_analyzer import _paper_position_part
part = _paper_position_part({"qty": 100, "avg_cost": 20.0}, close=11.0, raw_close=21.0)
assert "+5.00%" in part
assert "-45" not in part
+27
View File
@@ -1340,6 +1340,29 @@ export interface GroupStat {
win_rate: number
}
/** 回测候选 (candidates): 回测报告的持久化标量摘要; metrics 单位为小数 (0.052 = 5.2%) */
export interface BacktestCandidate {
id: string
kind: 'factor' | 'strategy'
name: string
source_id: string
metrics: Partial<{
total_return: number
annual_return: number
max_drawdown: number
sharpe: number
sortino: number
win_rate: number
n_trades: number
profit_factor: number
avg_return: number
median_return: number
}>
data_as_of: string | null
status: 'pending' | 'validated' | 'rejected'
created_at: string
}
export interface FactorBacktestResult {
run_id: string
config: Record<string, any>
@@ -2825,6 +2848,10 @@ export const api = {
backtestStatus: () => request<{ available: boolean }>('/api/backtest/status'),
/** 策略/因子候选 (回测报告的持久化摘要, 模拟盘对比用) */
backtestCandidates: () =>
request<{ items: BacktestCandidate[] }>('/api/backtest/candidates'),
backtestRun: (payload: {
symbols: string[]
entries: string[]
+1
View File
@@ -52,6 +52,7 @@ export const QK = {
// Backtest
backtestStatus: ['backtest-status'] as const,
backtestCandidates: ['backtest-candidates'] as const,
factorColumns: ['backtest-factor-columns'] as const,
factorLibrary: (assetType: string) => ['factors-library', assetType] as const,
miningRuns: ['backtest-mining-runs'] as const,
+87 -1
View File
@@ -313,6 +313,82 @@ function OrderForm({ acc, onDone }: { acc: string; onDone: () => void }) {
)
}
/** 策略候选对比 (V3): 模拟盘统计 vs 回测候选 (回测报告的持久化标量摘要, 口径与回测对齐)。
* 单位口径: 候选 metrics 为小数 (fmtPct 渲染), 模拟盘统计为百分数原值 — 各自按己方单位渲染, 不做数值换算。 */
interface PaperCompareStats {
total_return_pct: number | null
annual_pct: number | null
max_drawdown: number | null
win_rate: number | null
n_trades: number | null
profit_loss_ratio: number | null
}
function CandidateCompareCard({ paper }: { paper: PaperCompareStats }) {
const candsQ = useQuery({ queryKey: QK.backtestCandidates, queryFn: api.backtestCandidates })
const candidates = (candsQ.data?.items ?? []).filter(c => c.kind === 'strategy')
const [selId, setSelId] = useState('')
const sel = candidates.find(c => c.id === selId) ?? candidates[0]
const m = sel?.metrics ?? {}
const paperPct = (v: number | null | undefined) => (v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}%`)
const candPct = (v: number | null | undefined) => (v == null ? '—' : fmtPct(v))
const num = (v: number | null | undefined, digits = 0) => (v == null ? '—' : v.toFixed(digits))
const rows: Array<[string, string, string]> = [
['累计收益', paperPct(paper.total_return_pct), candPct(m.total_return)],
['年化收益', paperPct(paper.annual_pct), candPct(m.annual_return)],
['最大回撤', paper.max_drawdown != null ? `-${paper.max_drawdown.toFixed(2)}%` : '—', candPct(m.max_drawdown)],
['胜率', paper.win_rate != null ? `${paper.win_rate.toFixed(1)}%` : '—', candPct(m.win_rate)],
['交易次数', num(paper.n_trades), num(m.n_trades)],
['盈亏比', num(paper.profit_loss_ratio, 2), num(m.profit_factor, 2)],
['夏普比率', '—', num(m.sharpe, 2)],
]
return (
<div className="rounded-card border border-border/60 bg-surface p-3">
<div className="flex items-center gap-2">
<div className="shrink-0 text-sm font-medium">策略对比</div>
{candidates.length > 0 ? (
<select
value={sel?.id ?? ''}
onChange={e => setSelId(e.target.value)}
className="min-w-0 flex-1 rounded-btn border border-border bg-base px-1.5 py-1 text-xs text-secondary"
title="选择要对比的策略候选"
>
{candidates.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
) : (
<span className="text-[10px] text-muted">暂无策略候选</span>
)}
</div>
{sel ? (
<table className="mt-2 w-full text-[11px]">
<thead>
<tr className="text-muted">
<th className="py-0.5 text-left font-normal">指标</th>
<th className="py-0.5 text-right font-normal">模拟盘</th>
<th className="max-w-0 truncate py-0.5 text-right font-normal" title={sel.name}>{sel.name} (回测)</th>
</tr>
</thead>
<tbody className="font-mono">
{rows.map(([label, p, c]) => (
<tr key={label} className="border-t border-border/40">
<td className="py-1 font-sans text-muted">{label}</td>
<td className="py-1 text-right text-secondary">{p}</td>
<td className="py-1 text-right text-secondary">{c}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="mt-2 text-[11px] leading-relaxed text-muted">
在策略页保存回测候选后, 可与模拟盘同口径对比 (累计/年化/回撤/胜率等)。
</div>
)}
</div>
)
}
/** 自动跟单规则列表 + 新建表单 (V2): 监控事件 → 自动下单 (按账户隔离) */
function AutoRulesPanel({ acc }: { acc: string }) {
const qc = useQueryClient()
@@ -773,10 +849,20 @@ export function Paper() {
</div>
</div>
{/* 右列: 下单 + 自动跟单 */}
{/* 右列: 下单 + 自动跟单 + 策略对比 */}
<div className="space-y-4">
<OrderForm acc={accId} onDone={invalidateAll} />
<AutoRulesPanel acc={accId} />
<CandidateCompareCard paper={{
total_return_pct: nav.length >= 2 && nav[0].nav > 0 ? (nav[nav.length - 1].nav / nav[0].nav - 1) * 100 : null,
annual_pct: nav.length >= 2 && nav[0].nav > 0
? (Math.pow(nav[nav.length - 1].nav / nav[0].nav, 252 / nav.length) - 1) * 100
: null,
max_drawdown: stats?.max_drawdown ?? null,
win_rate: stats?.win_rate ?? null,
n_trades: allFills.filter(f => f.kind !== 'corp_action').length,
profit_loss_ratio: stats?.profit_loss_ratio ?? null,
}} />
<div className="rounded-card border border-border/60 bg-base/40 p-3 text-[11px] leading-relaxed text-muted">
<div className="font-medium text-secondary">撮合口径</div>
<div className="mt-1">· 即时单: 交易时段按最新快照价 + 滑点成交</div>