mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(paper): V3 策略候选对比卡 + AI 个股分析注入虚拟持仓
- 模拟盘右列新增「策略对比」卡: 统计 vs 策略候选 (回测报告持久化摘要, 同口径标量), 各按己方单位渲染 (候选小数 fmtPct / 模拟盘百分数), 不做数值换算 - api 客户端新增 backtestCandidates + BacktestCandidate 类型 + QK 键 - stock_analyzer: 模拟盘默认账户持有该标的时, 用户提示词注入虚拟持仓上下文 (数量/成本/浮盈, 浮盈优先 raw_close 口径避免除权失真; 注入失败静默跳过) - 测试: 注入/空仓/raw 口径 3 项 + 既有 46 项全过; 前端 build 通过
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user