438 lines
14 KiB
Python
438 lines
14 KiB
Python
"""持仓归因分析深化 — 选股/择时能力、持仓时长、理由有效性分析。
|
||
|
||
功能:
|
||
1. 收益归因分解(选股 vs 择时 vs 运气)
|
||
2. 持仓时长分析(短线/中线/长线胜率)
|
||
3. 买入理由有效性验证
|
||
4. 情绪标签相关性分析
|
||
5. 对标指数超额收益拆解
|
||
"""
|
||
import datetime as dt
|
||
from typing import Dict, List, Any, Tuple
|
||
from collections import defaultdict
|
||
import numpy as np
|
||
from sqlalchemy import select, and_
|
||
|
||
from db import get_session
|
||
from models import Trade, DailyQuote, IndexDaily, StockMetric
|
||
|
||
|
||
def analyze_attribution() -> Dict[str, Any]:
|
||
"""综合归因分析"""
|
||
with get_session() as s:
|
||
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
|
||
|
||
if not trades:
|
||
return {"ok": False, "msg": "暂无交易记录"}
|
||
|
||
stock_timing = analyze_stock_vs_timing(trades)
|
||
hold_period = analyze_hold_period(trades)
|
||
reason_valid = analyze_reason_validity(trades)
|
||
emotion_corr = analyze_emotion_correlation(trades)
|
||
excess_return = analyze_excess_return(trades)
|
||
|
||
return {
|
||
"ok": True,
|
||
"stock_vs_timing": stock_timing,
|
||
"hold_period": hold_period,
|
||
"reason_validity": reason_valid,
|
||
"emotion_correlation": emotion_corr,
|
||
"excess_return": excess_return,
|
||
}
|
||
|
||
|
||
def analyze_stock_vs_timing(trades: List[Trade]) -> Dict[str, Any]:
|
||
"""分解选股能力 vs 择时能力
|
||
|
||
选股能力:买入后股票的整体涨幅(持有期间市场表现)
|
||
择时能力:实际买卖点的精准度(买在低点、卖在高点)
|
||
运气成分:市场整体波动的影响
|
||
"""
|
||
with get_session() as s:
|
||
stock_trades = defaultdict(list)
|
||
for t in trades:
|
||
stock_trades[t.code].append(t)
|
||
|
||
results = []
|
||
total_stock_contrib = 0.0
|
||
total_timing_contrib = 0.0
|
||
|
||
for code, stock_trades_list in stock_trades.items():
|
||
dates = [t.date for t in stock_trades_list]
|
||
start = min(dates)
|
||
end = max(dates)
|
||
|
||
prices = {}
|
||
for d, close in s.execute(
|
||
select(DailyQuote.date, DailyQuote.close)
|
||
.where(
|
||
and_(
|
||
DailyQuote.code == code,
|
||
DailyQuote.date >= start,
|
||
DailyQuote.date <= end,
|
||
)
|
||
)
|
||
.order_by(DailyQuote.date)
|
||
).all():
|
||
prices[d] = float(close)
|
||
|
||
if not prices:
|
||
continue
|
||
|
||
first_price = prices[min(prices.keys())]
|
||
last_price = prices[max(prices.keys())]
|
||
stock_return = (last_price / first_price - 1) * 100
|
||
|
||
buys = [t for t in stock_trades_list if t.side == "buy"]
|
||
sells = [t for t in stock_trades_list if t.side == "sell"]
|
||
|
||
if buys and sells:
|
||
avg_buy = np.mean([t.price for t in buys])
|
||
avg_sell = np.mean([t.price for t in sells])
|
||
|
||
ideal_buy = min(prices.values())
|
||
ideal_sell = max(prices.values())
|
||
|
||
buy_timing = (
|
||
(1 - (avg_buy - ideal_buy) / (ideal_sell - ideal_buy)) * 100
|
||
if ideal_sell > ideal_buy
|
||
else 50
|
||
)
|
||
sell_timing = (
|
||
((avg_sell - ideal_buy) / (ideal_sell - ideal_buy)) * 100
|
||
if ideal_sell > ideal_buy
|
||
else 50
|
||
)
|
||
timing_score = (buy_timing + sell_timing) / 2
|
||
actual_return = (avg_sell / avg_buy - 1) * 100
|
||
|
||
results.append(
|
||
{
|
||
"code": code,
|
||
"stock_return": round(stock_return, 2),
|
||
"timing_score": round(timing_score, 1),
|
||
"actual_return": round(actual_return, 2),
|
||
}
|
||
)
|
||
|
||
total_stock_contrib += stock_return
|
||
total_timing_contrib += timing_score
|
||
|
||
if not results:
|
||
return {"ok": False, "msg": "数据不足"}
|
||
|
||
avg_stock = total_stock_contrib / len(results)
|
||
avg_timing = total_timing_contrib / len(results)
|
||
|
||
return {
|
||
"ok": True,
|
||
"stock_ability": round(avg_stock, 2),
|
||
"timing_ability": round(avg_timing, 1),
|
||
"interpretation": {
|
||
"stock": "正值表示选对了股票(股票整体上涨),负值表示选错了",
|
||
"timing": "100分满分,表示买卖点的精准度,50分为平均水平",
|
||
},
|
||
"by_stock": results,
|
||
}
|
||
|
||
|
||
def analyze_hold_period(trades: List[Trade]) -> Dict[str, Any]:
|
||
"""持仓时长分析
|
||
|
||
短线:持仓 <= 5天
|
||
中线:持仓 6-30天
|
||
长线:持仓 > 30天
|
||
"""
|
||
holdings = defaultdict(list)
|
||
closed_trades = []
|
||
|
||
for t in trades:
|
||
if t.side == "buy":
|
||
holdings[t.code].append({"trade": t, "qty": t.qty})
|
||
else:
|
||
remaining = t.qty
|
||
while remaining > 0 and holdings[t.code]:
|
||
hold = holdings[t.code][0]
|
||
sell_qty = min(remaining, hold["qty"])
|
||
|
||
hold_days = (t.date - hold["trade"].date).days
|
||
pnl = (t.price - hold["trade"].price) * sell_qty - t.fee * (
|
||
sell_qty / t.qty
|
||
)
|
||
pnl_pct = (t.price / hold["trade"].price - 1) * 100
|
||
|
||
closed_trades.append(
|
||
{
|
||
"code": t.code,
|
||
"buy_date": hold["trade"].date,
|
||
"sell_date": t.date,
|
||
"hold_days": hold_days,
|
||
"buy_price": hold["trade"].price,
|
||
"sell_price": t.price,
|
||
"qty": sell_qty,
|
||
"pnl": pnl,
|
||
"pnl_pct": pnl_pct,
|
||
}
|
||
)
|
||
|
||
hold["qty"] -= sell_qty
|
||
remaining -= sell_qty
|
||
|
||
if hold["qty"] <= 0:
|
||
holdings[t.code].pop(0)
|
||
|
||
if not closed_trades:
|
||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||
|
||
short_term = [t for t in closed_trades if t["hold_days"] <= 5]
|
||
mid_term = [t for t in closed_trades if 6 <= t["hold_days"] <= 30]
|
||
long_term = [t for t in closed_trades if t["hold_days"] > 30]
|
||
|
||
def calc_stats(trades_list):
|
||
if not trades_list:
|
||
return {"count": 0, "win_rate": 0, "avg_return": 0, "avg_days": 0}
|
||
wins = sum(1 for t in trades_list if t["pnl"] > 0)
|
||
return {
|
||
"count": len(trades_list),
|
||
"win_rate": round(wins / len(trades_list) * 100, 1),
|
||
"avg_return": round(np.mean([t["pnl_pct"] for t in trades_list]), 2),
|
||
"avg_days": round(np.mean([t["hold_days"] for t in trades_list]), 1),
|
||
"total_pnl": round(sum(t["pnl"] for t in trades_list), 2),
|
||
}
|
||
|
||
return {
|
||
"ok": True,
|
||
"short_term": calc_stats(short_term),
|
||
"mid_term": calc_stats(mid_term),
|
||
"long_term": calc_stats(long_term),
|
||
"recommendation": _recommend_hold_period(short_term, mid_term, long_term),
|
||
}
|
||
|
||
|
||
def _recommend_hold_period(short, mid, long) -> str:
|
||
"""推荐最佳持仓周期"""
|
||
periods = [
|
||
("短线(≤5天)", short),
|
||
("中线(6-30天)", mid),
|
||
("长线(>30天)", long),
|
||
]
|
||
|
||
if not any(p for _, p in periods):
|
||
return "数据不足"
|
||
|
||
scores = []
|
||
for name, trades_list in periods:
|
||
if not trades_list:
|
||
scores.append((name, 0))
|
||
continue
|
||
wins = sum(1 for t in trades_list if t["pnl"] > 0)
|
||
win_rate = wins / len(trades_list)
|
||
avg_ret = np.mean([t["pnl_pct"] for t in trades_list])
|
||
score = win_rate * 50 + (avg_ret / 10) * 50
|
||
scores.append((name, score))
|
||
|
||
scores.sort(key=lambda x: x[1], reverse=True)
|
||
best = scores[0][0]
|
||
|
||
return f"建议重点关注{best},该周期胜率和收益表现最佳"
|
||
|
||
|
||
def analyze_reason_validity(trades: List[Trade]) -> Dict[str, Any]:
|
||
"""买入理由有效性验证"""
|
||
holdings = defaultdict(list)
|
||
reason_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
|
||
|
||
for t in trades:
|
||
if t.side == "buy":
|
||
holdings[t.code].append(t)
|
||
else:
|
||
while t.qty > 0 and holdings[t.code]:
|
||
buy = holdings[t.code].pop(0)
|
||
qty = min(t.qty, buy.qty)
|
||
|
||
pnl = (t.price - buy.price) * qty
|
||
pnl_pct = (t.price / buy.price - 1) * 100
|
||
|
||
reason = buy.reason or "未标注"
|
||
reason_stats[reason]["trades"].append(pnl_pct)
|
||
reason_stats[reason]["total_pnl"] += pnl
|
||
if pnl > 0:
|
||
reason_stats[reason]["wins"] += 1
|
||
|
||
buy.qty -= qty
|
||
t.qty -= qty
|
||
if buy.qty > 0:
|
||
holdings[t.code].insert(0, buy)
|
||
|
||
if not reason_stats:
|
||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||
|
||
results = []
|
||
for reason, stats in reason_stats.items():
|
||
trades_list = stats["trades"]
|
||
results.append(
|
||
{
|
||
"reason": reason,
|
||
"count": len(trades_list),
|
||
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
|
||
"avg_return": round(np.mean(trades_list), 2),
|
||
"total_pnl": round(stats["total_pnl"], 2),
|
||
"effectiveness": "有效"
|
||
if stats["wins"] / len(trades_list) > 0.5
|
||
else "无效",
|
||
}
|
||
)
|
||
|
||
results.sort(key=lambda x: x["win_rate"], reverse=True)
|
||
|
||
return {
|
||
"ok": True,
|
||
"by_reason": results,
|
||
"best_reason": results[0]["reason"] if results else None,
|
||
"worst_reason": results[-1]["reason"] if results else None,
|
||
}
|
||
|
||
|
||
def analyze_emotion_correlation(trades: List[Trade]) -> Dict[str, Any]:
|
||
"""情绪标签相关性分析"""
|
||
holdings = defaultdict(list)
|
||
emotion_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
|
||
|
||
for t in trades:
|
||
if t.side == "buy":
|
||
holdings[t.code].append(t)
|
||
else:
|
||
while t.qty > 0 and holdings[t.code]:
|
||
buy = holdings[t.code].pop(0)
|
||
qty = min(t.qty, buy.qty)
|
||
|
||
pnl = (t.price - buy.price) * qty
|
||
pnl_pct = (t.price / buy.price - 1) * 100
|
||
|
||
emotion = buy.emotion or "未标注"
|
||
emotion_stats[emotion]["trades"].append(pnl_pct)
|
||
emotion_stats[emotion]["total_pnl"] += pnl
|
||
if pnl > 0:
|
||
emotion_stats[emotion]["wins"] += 1
|
||
|
||
buy.qty -= qty
|
||
t.qty -= qty
|
||
if buy.qty > 0:
|
||
holdings[t.code].insert(0, buy)
|
||
|
||
if not emotion_stats:
|
||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||
|
||
results = []
|
||
for emotion, stats in emotion_stats.items():
|
||
trades_list = stats["trades"]
|
||
results.append(
|
||
{
|
||
"emotion": emotion,
|
||
"count": len(trades_list),
|
||
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
|
||
"avg_return": round(np.mean(trades_list), 2),
|
||
"total_pnl": round(stats["total_pnl"], 2),
|
||
}
|
||
)
|
||
|
||
results.sort(key=lambda x: x["avg_return"], reverse=True)
|
||
|
||
return {
|
||
"ok": True,
|
||
"by_emotion": results,
|
||
"advice": _generate_emotion_advice(results),
|
||
}
|
||
|
||
|
||
def _generate_emotion_advice(results: List[Dict]) -> str:
|
||
"""生成情绪建议"""
|
||
if not results:
|
||
return "数据不足"
|
||
|
||
best = results[0]
|
||
worst = results[-1]
|
||
|
||
advice = (
|
||
f"最佳情绪状态:{best['emotion']}(胜率{best['win_rate']}%,"
|
||
f"平均收益{best['avg_return']}%)\n"
|
||
)
|
||
advice += (
|
||
f"最差情绪状态:{worst['emotion']}(胜率{worst['win_rate']}%,"
|
||
f"平均收益{worst['avg_return']}%)\n"
|
||
)
|
||
advice += "\n建议:保持理性和纪律,避免在贪婪或恐慌时做决策"
|
||
|
||
return advice
|
||
|
||
|
||
def analyze_excess_return(trades: List[Trade]) -> Dict[str, Any]:
|
||
"""对标指数超额收益拆解"""
|
||
if not trades:
|
||
return {"ok": False, "msg": "暂无交易记录"}
|
||
|
||
start_date = min(t.date for t in trades)
|
||
end_date = max(t.date for t in trades)
|
||
|
||
with get_session() as s:
|
||
index_data = s.execute(
|
||
select(IndexDaily.date, IndexDaily.close)
|
||
.where(
|
||
and_(
|
||
IndexDaily.code == "sh000300",
|
||
IndexDaily.date >= start_date,
|
||
IndexDaily.date <= end_date,
|
||
)
|
||
)
|
||
.order_by(IndexDaily.date)
|
||
).all()
|
||
|
||
if not index_data:
|
||
return {"ok": False, "msg": "缺少指数数据"}
|
||
|
||
index_start = float(index_data[0][1])
|
||
index_end = float(index_data[-1][1])
|
||
index_return = (index_end / index_start - 1) * 100
|
||
|
||
holdings = defaultdict(lambda: {"qty": 0, "cost": 0.0})
|
||
realized_pnl = 0.0
|
||
total_cost = 0.0
|
||
|
||
for t in trades:
|
||
p = holdings[t.code]
|
||
if t.side == "buy":
|
||
p["cost"] += t.price * t.qty + t.fee
|
||
p["qty"] += t.qty
|
||
total_cost += t.price * t.qty + t.fee
|
||
else:
|
||
if p["qty"] > 0:
|
||
avg = p["cost"] / p["qty"]
|
||
qty = min(t.qty, p["qty"])
|
||
pnl = (t.price - avg) * qty - t.fee
|
||
realized_pnl += pnl
|
||
p["cost"] -= avg * qty
|
||
p["qty"] -= qty
|
||
|
||
portfolio_return = (realized_pnl / total_cost * 100) if total_cost > 0 else 0
|
||
excess_return = portfolio_return - index_return
|
||
|
||
if excess_return > 0:
|
||
source = "选股能力贡献"
|
||
interpretation = "组合表现优于大盘,说明选股和择时能力较好"
|
||
elif excess_return < -5:
|
||
source = "选股/择时失误"
|
||
interpretation = "组合表现明显弱于大盘,建议反思选股逻辑和买卖时机"
|
||
else:
|
||
source = "与大盘持平"
|
||
interpretation = "组合表现与大盘接近,可考虑增强选股策略"
|
||
|
||
return {
|
||
"ok": True,
|
||
"portfolio_return": round(portfolio_return, 2),
|
||
"index_return": round(index_return, 2),
|
||
"excess_return": round(excess_return, 2),
|
||
"source": source,
|
||
"interpretation": interpretation,
|
||
"period": f"{start_date} ~ {end_date}",
|
||
}
|