新增持仓分析 prompt 生成功能

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fish
2026-07-27 10:43:05 +08:00
parent 1faeff4ee9
commit c291f331fc
3 changed files with 300 additions and 1 deletions
+229
View File
@@ -0,0 +1,229 @@
"""持仓分析 prompt 生成器。
根据最近 5 个交易日的主力持仓排名生成结构化分析 prompt,
按席位属性(家人/大户/其他/噪音)分类汇总,聚焦资金风向变化。
"""
from collections import defaultdict
from datetime import date, timedelta
from app.database import SessionLocal
from app.models import PositionRanking, Contract
# ── 席位分类 ──
FAMILY_NAMES = [
"东方财富", "徽商期货", "方正中期", "中信建投", "平安期货",
]
WHALE_NAMES = [
"永安期货", "中信期货", "国泰君安", "海通期货", "华泰期货",
"高盛期货", "乾坤期货", "摩根大通", "中粮期货",
]
NOISE_NAMES = ["东证期货"]
TYPE_LABEL = {"volume": "成交量", "long": "多单持仓", "short": "空单持仓"}
def _classify(inst: str) -> str:
"""匹配席位分类,返回 家人/大户/噪音/其他。"""
for kw in FAMILY_NAMES:
if kw in inst:
return "家人"
for kw in WHALE_NAMES:
if kw in inst:
return "大户"
for kw in NOISE_NAMES:
if kw in inst:
return "噪音"
return "其他"
def _build_legend() -> str:
"""生成席位分类图例。"""
lines = ["## 席位分类", ""]
lines.append(f"- **家人席位(散户)**: {''.join(FAMILY_NAMES)}")
lines.append(f"- **大户席位(产业/套保)**: {''.join(WHALE_NAMES)}")
lines.append(f"- **噪音席位(忽略)**: {''.join(NOISE_NAMES)}")
lines.append("- **其他席位**: 未归入以上三类的席位")
lines.append("")
return "\n".join(lines)
def _format_number(n: int) -> str:
"""数字格式化为万手。"""
v = n / 10000
sign = "+" if n > 0 else ""
return f"{sign}{v:.2f}"
def build_prompt(contract_code: str | None = None) -> str:
"""生成单个或全部活跃合约的持仓分析 prompt。
Args:
contract_code: 合约代码(如 FG2609),为 None 时分析所有活跃合约。
Returns:
可直接发送给 AI 进行分析的 prompt 文本。
"""
db = SessionLocal()
try:
if contract_code:
codes = [contract_code.upper()]
else:
codes = [
r[0] for r in
db.query(Contract.code)
.filter(Contract.is_active == True)
.order_by(Contract.code)
.all()
]
# 取最近 5 个有持仓数据的交易日
all_dates = sorted({
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code.in_(codes))
.distinct()
.all()
}, reverse=True)[:5]
all_dates.reverse() # 从旧到新
if not all_dates:
return "无持仓数据"
# 拉取全部数据
rankings = (
db.query(PositionRanking)
.filter(
PositionRanking.contract_code.in_(codes),
PositionRanking.date.in_(all_dates),
)
.order_by(PositionRanking.contract_code, PositionRanking.date, PositionRanking.data_type, PositionRanking.rank)
.all()
)
# 按 contract → date → data_type 组织
data: dict[str, dict] = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
for r in rankings:
data[r.contract_code][r.date][r.data_type].append(r)
parts = [_build_legend()]
for code in codes:
if code not in data:
continue
cd = data[code]
parts.append(f"## {code}")
parts.append("")
parts.append(f"分析区间: {min(all_dates)}{max(all_dates)}(共 {len(all_dates)} 个交易日)")
parts.append("")
# ── 每日明细 ──
for d in all_dates:
if d not in cd:
continue
parts.append(f"### {d} {_weekday_zh(d)}")
parts.append("")
for dtype in ["volume", "long", "short"]:
rows = cd[d].get(dtype, [])
if not rows:
continue
parts.append(f"**{TYPE_LABEL[dtype]} Top 20**")
parts.append("")
parts.append("| 排名 | 席位 | 分类 | 持仓量 | 增减 |")
parts.append("|------|------|------|--------|------|")
for r in rows[:20]:
tag = _classify(r.institution)
if tag == "噪音":
continue # 跳过东证
parts.append(
f"| {r.rank} | {r.institution} | {tag} | "
f"{_format_number(r.value)} | {_format_number(r.change)} |"
)
parts.append("")
# ── 每日家人 vs 大户汇总 ──
parts.append(f"### {code} 分类资金变化汇总")
parts.append("")
parts.append("| 日期 | 家人多单增减 | 家人空单增减 | 大户多单增减 | 大户空单增减 | 其他多单增减 | 其他空单增减 |")
parts.append("|------|-------------|-------------|-------------|-------------|-------------|-------------|")
for d in all_dates:
if d not in cd:
continue
cat_changes = defaultdict(lambda: defaultdict(int))
for dtype in ["long", "short"]:
for r in cd[d].get(dtype, []):
tag = _classify(r.institution)
if tag == "噪音":
continue
cat_changes[tag][dtype] += r.change
parts.append(
f"| {d} "
f"| {_format_number(cat_changes['家人']['long'])} "
f"| {_format_number(cat_changes['家人']['short'])} "
f"| {_format_number(cat_changes['大户']['long'])} "
f"| {_format_number(cat_changes['大户']['short'])} "
f"| {_format_number(cat_changes['其他']['long'])} "
f"| {_format_number(cat_changes['其他']['short'])} |"
)
parts.append("")
# ── 5日累计 ──
parts.append(f"### {code} 5日累计资金变化")
parts.append("")
total_cat = defaultdict(lambda: defaultdict(int))
for d in all_dates:
if d not in cd:
continue
for dtype in ["long", "short"]:
for r in cd[d].get(dtype, []):
tag = _classify(r.institution)
if tag == "噪音":
continue
total_cat[tag][dtype] += r.change
parts.append("| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空) |")
parts.append("|------|------------|------------|--------------|")
for tag in ["家人", "大户", "其他"]:
net = total_cat[tag]["long"] - total_cat[tag]["short"]
net_str = _format_number(net)
parts.append(
f"| {tag} | {_format_number(total_cat[tag]['long'])} "
f"| {_format_number(total_cat[tag]['short'])} | {net_str} |"
)
parts.append("")
# ── 分析引导 ──
parts.append("---")
parts.append("")
parts.append("请基于以上数据进行分析,重点关注:")
parts.append("")
parts.append("1. **家人席位动向**: 5日内多空增减趋势,散户情绪偏多还是偏空,"
"是否有明显的追涨杀跌行为。")
parts.append("2. **大户席位动向**: 产业资金和套保资金的布局方向,"
"多空增减是否与家人席位形成对手盘。")
parts.append("3. **多空力量对比**: 各分类的净多/净空方向,"
"整体市场情绪偏向。")
parts.append("4. **异常信号**: 单日大幅增减的席位,"
"分类资金出现明显分歧的交易日。")
parts.append("5. **行情预判**: 结合席位资金流向,"
"对下一交易日走势给出偏多/偏空/震荡的判断及理由。")
return "\n".join(parts)
finally:
db.close()
def _weekday_zh(d: date) -> str:
return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][d.weekday()]
if __name__ == "__main__":
import sys
sys.path.insert(0, "/app")
from app.prompt_builder import build_prompt
prompt = build_prompt()
print(prompt)
+8 -1
View File
@@ -1,10 +1,11 @@
import math import math
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request, Query from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, PlainTextResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import DailyBar, Contract, PositionRanking from app.models import DailyBar, Contract, PositionRanking
from app.prompt_builder import build_prompt
router = APIRouter(prefix="/contracts", tags=["contracts"]) router = APIRouter(prefix="/contracts", tags=["contracts"])
@@ -205,3 +206,9 @@ def contract_detail(
WEEKDAY_ZH=WEEKDAY_ZH, WEEKDAY_ZH=WEEKDAY_ZH,
) )
) )
@router.get("/{contract}/analysis-prompt", response_class=PlainTextResponse)
def contract_analysis_prompt(contract: str):
"""返回合约的持仓分析 prompt 文本。"""
return build_prompt(contract.upper())
+63
View File
@@ -72,6 +72,7 @@
<div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;"> <div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;">
<span>持仓排名 · 前20</span> <span>持仓排名 · 前20</span>
<input type="date" id="pos-date-select" value="{{ selected_pos_date }}" min="{{ pos_dates[-1] }}" max="{{ pos_dates[0] }}" onchange="changePosDate(this.value)" style="font-size:0.82rem;padding:4px 10px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;"> <input type="date" id="pos-date-select" value="{{ selected_pos_date }}" min="{{ pos_dates[-1] }}" max="{{ pos_dates[0] }}" onchange="changePosDate(this.value)" style="font-size:0.82rem;padding:4px 10px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;">
<button onclick="openAnalysisDrawer()" style="margin-left:auto;padding:6px 16px;border:1px solid var(--accent);border-radius:5px;background:var(--accent-light);color:var(--accent);cursor:pointer;font-size:0.82rem;font-weight:600;white-space:nowrap;">AI 分析</button>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;"> <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;">
@@ -108,6 +109,18 @@
</div> </div>
{% endif %} {% endif %}
<div id="analysisOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="closeAnalysisDrawer()"></div>
<div id="analysisDrawer" style="display:none;position:fixed;top:0;right:0;width:700px;max-width:95vw;height:100%;background:var(--surface);border-left:1px solid var(--border);z-index:100;box-shadow:-4px 0 24px rgba(0,0,0,.12);transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:1rem;">持仓分析 {{ contract }}</h3>
<div style="display:flex;gap:8px;">
<button onclick="copyPrompt()" style="padding:4px 12px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.78rem;">复制</button>
<button onclick="closeAnalysisDrawer()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);line-height:1;">&times;</button>
</div>
</div>
<pre id="analysisContent" style="flex:1;margin:0;padding:16px 20px;overflow-y:auto;font-size:0.78rem;line-height:1.6;white-space:pre-wrap;word-break:break-all;color:var(--fg);background:var(--bg);">加载中...</pre>
</div>
<script> <script>
function changePosDate(d) { function changePosDate(d) {
var url = new URL(window.location); var url = new URL(window.location);
@@ -163,5 +176,55 @@ function showDrawer(cell, product, calcRows, isNext) {
activeRow = row; activeRow = row;
} }
} }
var promptText = '';
function openAnalysisDrawer() {
var overlay = document.getElementById('analysisOverlay');
var drawer = document.getElementById('analysisDrawer');
drawer.style.display = 'flex';
overlay.style.display = 'block';
requestAnimationFrame(function() {
drawer.style.transform = 'translateX(0)';
});
var content = document.getElementById('analysisContent');
if (promptText) {
content.textContent = promptText;
return;
}
content.textContent = '加载中...';
fetch('/contracts/{{ contract }}/analysis-prompt')
.then(function(r) { return r.text(); })
.then(function(t) {
promptText = t;
content.textContent = t;
})
.catch(function() { content.textContent = '加载失败'; });
}
function closeAnalysisDrawer() {
document.getElementById('analysisOverlay').style.display = 'none';
document.getElementById('analysisDrawer').style.transform = 'translateX(100%)';
}
function copyPrompt() {
navigator.clipboard.writeText(promptText).then(function() {
showToast('已复制到剪贴板');
});
}
function showToast(msg) {
var t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
t.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#1e293b;color:#fff;padding:10px 24px;border-radius:8px;font-size:0.88rem;z-index:999;transition:opacity .3s,transform .3s;opacity:0;pointer-events:none;';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
t.style.transform = 'translateX(-50%) translateY(0)';
clearTimeout(t._tid);
t._tid = setTimeout(function() {
t.style.opacity = '0';
t.style.transform = 'translateX(-50%) translateY(-10px)';
}, 1500);
}
</script> </script>
{% endblock %} {% endblock %}