You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
buffer_platform/app/services/trade_pairing_engine.py

301 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
交易配对引擎 - 将开仓和平仓配对为完整交易,持久化到 TradePair 表
"""
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from collections import defaultdict
from app.analysis_models import TradeRecord, TradePair, TradeReflection
logger = logging.getLogger(__name__)
def auto_pair_trades(db: Session, trade_date: str) -> dict:
"""
自动配对指定日期的交易记录
按品种+方向分组FIFO 规则配对
返回:{created: 新建配对数, updated: 更新配对数, unpaired: 未配对记录数}
"""
records = db.query(TradeRecord).filter(
TradeRecord.trade_date == trade_date
).order_by(
TradeRecord.variety, TradeRecord.symbol, TradeRecord.trade_date, TradeRecord.trade_time
).all()
if not records:
return {"created": 0, "updated": 0, "unpaired": 0}
# 按品种分组
by_variety = defaultdict(list)
for r in records:
by_variety[r.variety].append(r)
result = {"created": 0, "updated": 0, "unpaired": 0}
for variety, recs in by_variety.items():
pair_result = _pair_single_variety(db, variety, recs)
result["created"] += pair_result["created"]
result["updated"] += pair_result["updated"]
result["unpaired"] += pair_result["unpaired"]
return result
def _pair_single_variety(db: Session, variety: str, records: list) -> dict:
"""单个品种的配对逻辑"""
# 分离开仓和平仓
opens = [r for r in records if r.offset == '']
closes = [r for r in records if r.offset == '']
result = {"created": 0, "updated": 0, "unpaired": 0}
# 按方向分组
long_opens = [r for r in opens if r.direction == '']
short_opens = [r for r in opens if r.direction == '']
long_closes = [r for r in closes if r.direction == ''] # 卖平 = 多头平仓
short_closes = [r for r in closes if r.direction == ''] # 买平 = 空头平仓
# 多头配对
result["created"] += _pair_direction(db, variety, long_opens, long_closes, "long")
# 空头配对
result["created"] += _pair_direction(db, variety, short_opens, short_closes, "short")
# 统计未配对
all_paired_open_ids = set()
all_paired_close_ids = set()
existing_pairs = db.query(TradePair).filter(
TradePair.variety == variety,
TradePair.open_date >= records[0].trade_date if records else None
).all()
for p in existing_pairs:
all_paired_open_ids.update(p.open_record_ids)
all_paired_close_ids.update(p.close_record_ids)
unpaired_count = 0
for r in records:
if r.id not in all_paired_open_ids and r.id not in all_paired_close_ids:
unpaired_count += 1
result["unpaired"] = unpaired_count
return result
def _pair_direction(db: Session, variety: str, opens: list, closes: list, direction: str) -> int:
"""单方向配对FIFO返回新建配对数"""
created = 0
open_queue = list(opens) # FIFO 队列
close_queue = list(closes)
while open_queue and close_queue:
open_rec = open_queue[0]
close_rec = close_queue[0]
open_volume = open_rec.volume or 0
close_volume = close_rec.volume or 0
# 计算配对手数
pair_volume = min(open_volume, close_volume)
# 计算平均价格
open_price = open_rec.price
close_price = close_rec.price
# 计算盈亏
close_pnl = close_rec.close_pnl or 0
total_commission = (open_rec.commission or 0) + (close_rec.commission or 0)
net_pnl = close_pnl - total_commission
# 创建配对记录
pair = TradePair(
variety=variety,
direction=direction,
open_record_ids=[open_rec.id],
close_record_ids=[close_rec.id],
open_price=open_price,
close_price=close_price,
total_volume=pair_volume,
close_pnl=close_pnl,
total_commission=total_commission,
net_pnl=net_pnl,
open_date=open_rec.trade_date,
close_date=close_rec.trade_date,
)
db.add(pair)
created += 1
# 从队列中移除或更新剩余手数
if open_volume == pair_volume:
open_queue.pop(0)
else:
open_rec.volume = open_volume - pair_volume
if close_volume == pair_volume:
close_queue.pop(0)
else:
close_rec.volume = close_volume - pair_volume
db.flush()
return created
def get_daily_trades_with_pairs(db: Session, trade_date: str) -> dict:
"""
获取指定日期的交易数据,包含配对信息和反思状态
返回:{date, stats, pairs, unpaired_records, daily_reflection}
"""
# 获取所有配对
pairs = db.query(TradePair).filter(
(TradePair.open_date == trade_date) | (TradePair.close_date == trade_date)
).order_by(TradePair.open_date, TradePair.close_date).all()
# 获取所有交易记录
records = db.query(TradeRecord).filter(
TradeRecord.trade_date == trade_date
).order_by(TradeRecord.trade_date, TradeRecord.trade_time).all()
# 计算统计
total_pnl = sum(p.net_pnl or 0 for p in pairs)
total_trades = len(pairs) + len(records) # 配对 + 未配对
winning_trades = sum(1 for p in pairs if (p.net_pnl or 0) > 0)
win_rate = round(winning_trades / total_trades * 100, 1) if total_trades > 0 else 0
# 获取反思状态
pair_ids = [p.id for p in pairs]
reflections = db.query(TradeReflection).filter(
TradeReflection.trade_pair_id.in_(pair_ids)
).all()
reflection_map = {r.trade_pair_id: r for r in reflections}
# 获取当日反思
daily_reflection = db.query(DailyReflection).filter(
DailyReflection.reflection_date == trade_date
).first()
# 构建返回数据
pairs_data = []
for p in pairs:
reflection = reflection_map.get(p.id)
pairs_data.append({
"id": p.id,
"variety": p.variety,
"direction": p.direction,
"open_price": p.open_price,
"close_price": p.close_price,
"total_volume": p.total_volume,
"close_pnl": p.close_pnl,
"total_commission": p.total_commission,
"net_pnl": p.net_pnl,
"open_date": p.open_date,
"close_date": p.close_date,
"open_record_ids": p.open_record_ids,
"close_record_ids": p.close_record_ids,
"reflection_status": "done" if reflection else ("pending" if _has_unpaired_records(db, p) else "none"),
"ai_analyzed": reflection.ai_analyzed if reflection else False,
"ai_version": reflection.ai_version if reflection else None,
})
# 未配对记录
paired_record_ids = set()
for p in pairs:
paired_record_ids.update(p.open_record_ids)
paired_record_ids.update(p.close_record_ids)
unpaired = [r for r in records if r.id not in paired_record_ids]
unpaired_data = [{
"id": r.id,
"symbol": r.symbol,
"variety": r.variety,
"direction": r.direction,
"offset": r.offset,
"price": r.price,
"volume": r.volume,
"trade_date": r.trade_date,
"trade_time": r.trade_time,
} for r in unpaired]
return {
"date": trade_date,
"stats": {
"total_pnl": round(total_pnl, 2),
"total_trades": total_trades,
"winning_trades": winning_trades,
"win_rate": win_rate,
"paired_count": len(pairs),
"unpaired_count": len(unpaired),
"reflected_count": sum(1 for r in reflection_map.values()),
},
"pairs": pairs_data,
"unpaired_records": unpaired_data,
"daily_reflection": {
"id": daily_reflection.id if daily_reflection else None,
"emotion_state": daily_reflection.emotion_state if daily_reflection else None,
"market_judgment": daily_reflection.market_judgment if daily_reflection else None,
"discipline_score": daily_reflection.discipline_score if daily_reflection else None,
"overall_rating": daily_reflection.overall_rating if daily_reflection else None,
"summary": daily_reflection.summary if daily_reflection else None,
"improvements": daily_reflection.improvements if daily_reflection else None,
} if daily_reflection else None,
}
def _has_unpaired_records(db: Session, pair: TradePair) -> bool:
"""检查配对是否有未配对的开仓或平仓记录"""
# 简化逻辑:如果开仓日期和平仓日期不同,可能存在跨日未配对
return pair.open_date != pair.close_date
def create_manual_pair(db: Session, open_record_ids: list[int], close_record_ids: list[int]) -> TradePair:
"""手动创建配对"""
open_records = db.query(TradeRecord).filter(TradeRecord.id.in_(open_record_ids)).all()
close_records = db.query(TradeRecord).filter(TradeRecord.id.in_(close_record_ids)).all()
if not open_records or not close_records:
raise ValueError("开仓或平仓记录不存在")
variety = open_records[0].variety
direction = "long" if open_records[0].direction == '' else "short"
# 计算统计
total_volume = sum(r.volume or 0 for r in open_records)
total_close_pnl = sum(r.close_pnl or 0 for r in close_records)
total_commission = sum((r.commission or 0) for r in open_records) + sum((r.commission or 0) for r in close_records)
net_pnl = total_close_pnl - total_commission
avg_open_price = sum(r.price or 0 for r in open_records) / len(open_records) if open_records else 0
avg_close_price = sum(r.price or 0 for r in close_records) / len(close_records) if close_records else 0
pair = TradePair(
variety=variety,
direction=direction,
open_record_ids=open_record_ids,
close_record_ids=close_record_ids,
open_price=round(avg_open_price, 2),
close_price=round(avg_close_price, 2),
total_volume=total_volume,
close_pnl=total_close_pnl,
total_commission=total_commission,
net_pnl=round(net_pnl, 2),
open_date=open_records[0].trade_date,
close_date=close_records[-1].trade_date,
)
db.add(pair)
db.flush()
return pair
def delete_pair(db: Session, pair_id: int) -> bool:
"""删除配对(不删除原始交易记录)"""
pair = db.query(TradePair).filter(TradePair.id == pair_id).first()
if not pair:
return False
# 删除关联的反思
db.query(TradeReflection).filter(TradeReflection.trade_pair_id == pair_id).delete()
# 删除配对
db.delete(pair)
db.flush()
return True