新增持仓分析 prompt 生成功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -1,10 +1,11 @@
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import DailyBar, Contract, PositionRanking
|
||||
from app.prompt_builder import build_prompt
|
||||
|
||||
router = APIRouter(prefix="/contracts", tags=["contracts"])
|
||||
|
||||
@@ -205,3 +206,9 @@ def contract_detail(
|
||||
WEEKDAY_ZH=WEEKDAY_ZH,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{contract}/analysis-prompt", response_class=PlainTextResponse)
|
||||
def contract_analysis_prompt(contract: str):
|
||||
"""返回合约的持仓分析 prompt 文本。"""
|
||||
return build_prompt(contract.upper())
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;">
|
||||
<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;">
|
||||
<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 style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;">
|
||||
@@ -108,6 +109,18 @@
|
||||
</div>
|
||||
{% 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;">×</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>
|
||||
function changePosDate(d) {
|
||||
var url = new URL(window.location);
|
||||
@@ -163,5 +176,55 @@ function showDrawer(cell, product, calcRows, isNext) {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user