From 7168d8f3e9d23f249b6e6d07dfe1cd4ae2cfc9f9 Mon Sep 17 00:00:00 2001 From: Lxy Date: Mon, 6 Jul 2026 22:20:11 +0800 Subject: [PATCH] fix: restore trade reflection system code that was accidentally deleted --- app/analysis_db.py | 98 +- app/analysis_models.py | 152 +++ app/api/trade_review.py | 547 ++++++++++ app/services/reflection_ai_analysis.py | 335 ++++++ app/services/trade_pairing_engine.py | 336 ++++++ app/static/futures_analysis.html | 610 +++++++++-- app/static/futures_analysis.js | 980 +++++++++++++++++- docs/futures_analysis_module.md | 322 ++++++ ...26-07-05-trade-reflection-system-design.md | 575 ++++++++++ .../trade-reflection-system/.comet.yaml | 15 + .../.comet/handoff/brainstorm-summary.md | 38 + .../.comet/handoff/design-context.md | 33 + .../trade-reflection-system/.openspec.yaml | 2 + .../trade-reflection-system/proposal.md | 33 + .../specs/ai-reanalysis/spec.md | 35 + .../specs/daily-reflection/spec.md | 21 + .../specs/experience-library/spec.md | 32 + .../specs/trade-pairing/spec.md | 34 + .../specs/trade-reflection/spec.md | 26 + .../specs/trade-tagging/spec.md | 37 + .../archive/trade-reflection-system/tasks.md | 120 +++ tests/test_trade_reflection.py | 287 +++++ 22 files changed, 4585 insertions(+), 83 deletions(-) create mode 100644 app/services/reflection_ai_analysis.py create mode 100644 app/services/trade_pairing_engine.py create mode 100644 docs/futures_analysis_module.md create mode 100644 docs/superpowers/specs/2026-07-05-trade-reflection-system-design.md create mode 100644 openspec/archive/trade-reflection-system/.comet.yaml create mode 100644 openspec/archive/trade-reflection-system/.comet/handoff/brainstorm-summary.md create mode 100644 openspec/archive/trade-reflection-system/.comet/handoff/design-context.md create mode 100644 openspec/archive/trade-reflection-system/.openspec.yaml create mode 100644 openspec/archive/trade-reflection-system/proposal.md create mode 100644 openspec/archive/trade-reflection-system/specs/ai-reanalysis/spec.md create mode 100644 openspec/archive/trade-reflection-system/specs/daily-reflection/spec.md create mode 100644 openspec/archive/trade-reflection-system/specs/experience-library/spec.md create mode 100644 openspec/archive/trade-reflection-system/specs/trade-pairing/spec.md create mode 100644 openspec/archive/trade-reflection-system/specs/trade-reflection/spec.md create mode 100644 openspec/archive/trade-reflection-system/specs/trade-tagging/spec.md create mode 100644 openspec/archive/trade-reflection-system/tasks.md create mode 100644 tests/test_trade_reflection.py diff --git a/app/analysis_db.py b/app/analysis_db.py index c8a04fa..0f17499 100644 --- a/app/analysis_db.py +++ b/app/analysis_db.py @@ -7,6 +7,9 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base from datetime import datetime +from app.mysql_database import MySQLSessionLocal +from app.storage_manager import get_storage_manager + logger = logging.getLogger(__name__) # 数据目录 @@ -28,14 +31,38 @@ AnalysisBase = declarative_base() def get_analysis_db(): """获取期货智析数据库会话。 - 分析数据量较小(复盘计划、评分等),始终使用 SQLite 以保证稳定性。 - AnalysisBase 绑定的是 analysis_engine (SQLite),ORM 模型均基于此定义。 + 根据 MySQL 可用性动态选择后端: + - MySQL 可用时返回 MySQL Session + - MySQL 不可用时返回 SQLite Session(兜底) """ - db = AnalysisSessionLocal() - try: - yield db - finally: - db.close() + storage = get_storage_manager() + + if MySQLSessionLocal is not None and storage.check_mysql(): + db = None + try: + db = MySQLSessionLocal() + yield db + except Exception as e: + logger.warning(f"创建 MySQL analysis session 失败,降级到 SQLite: {e}") + if db is not None: + try: + db.close() + except Exception: + pass + db = AnalysisSessionLocal() + yield db + finally: + if db is not None: + try: + db.close() + except Exception: + pass + else: + db = AnalysisSessionLocal() + try: + yield db + finally: + db.close() def init_analysis_db(): @@ -48,12 +75,17 @@ def init_analysis_db(): AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan, SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2, TradeRecord, TradeImportBatch, + TradePair, TradeReflection, DailyReflection, TradeTag, TradeRecordTag, TradeExperience, + TradeAIAnalysis, ) AnalysisBase.metadata.create_all(bind=analysis_engine) # 迁移:为已有表添加新增列(SQLite 不支持 IF NOT EXISTS,用异常处理) _migrate_add_columns() + # 初始化预设标签 + _init_preset_tags() + def init_analysis_mysql(mysql_engine): """初始化期货智析数据库表(MySQL)。""" @@ -63,6 +95,8 @@ def init_analysis_mysql(mysql_engine): AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan, SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2, TradeRecord, TradeImportBatch, + TradePair, TradeReflection, DailyReflection, TradeTag, TradeRecordTag, TradeExperience, + TradeAIAnalysis, ) AnalysisBase.metadata.create_all(bind=mysql_engine) logger.info("MySQL analysis 表结构初始化完成") @@ -88,3 +122,53 @@ def _migrate_add_columns(): conn.commit() conn.close() + + +def _init_preset_tags(): + """初始化预设标签""" + from app.analysis_models import TradeTag + from sqlalchemy import inspect + + session = AnalysisSessionLocal() + try: + # 检查表是否存在 + inspector = inspect(session.get_bind()) + if 'trade_tags' not in inspector.get_table_names(): + return + + # 检查是否已有预设标签 + existing = session.query(TradeTag).filter_by(is_preset=True).first() + if existing: + return + + preset_tags = [ + # 操作类 + TradeTag(name="追涨杀跌", category="operation", is_preset=True), + TradeTag(name="逆市操作", category="operation", is_preset=True), + TradeTag(name="提前入场", category="operation", is_preset=True), + TradeTag(name="延迟出场", category="operation", is_preset=True), + TradeTag(name="加仓过早", category="operation", is_preset=True), + TradeTag(name="减仓过晚", category="operation", is_preset=True), + # 心态类 + TradeTag(name="情绪化交易", category="mindset", is_preset=True), + TradeTag(name="恐惧平仓", category="mindset", is_preset=True), + TradeTag(name="贪婪持仓", category="mindset", is_preset=True), + TradeTag(name="侥幸心理", category="mindset", is_preset=True), + # 纪律类 + TradeTag(name="严格执行计划", category="discipline", is_preset=True), + TradeTag(name="违反止损", category="discipline", is_preset=True), + TradeTag(name="超仓交易", category="discipline", is_preset=True), + TradeTag(name="频繁交易", category="discipline", is_preset=True), + # 技术类 + TradeTag(name="突破交易", category="technical", is_preset=True), + TradeTag(name="回调交易", category="technical", is_preset=True), + TradeTag(name="趋势跟踪", category="technical", is_preset=True), + TradeTag(name="震荡交易", category="technical", is_preset=True), + TradeTag(name="反转交易", category="technical", is_preset=True), + ] + session.add_all(preset_tags) + session.commit() + except Exception: + session.rollback() + finally: + session.close() diff --git a/app/analysis_models.py b/app/analysis_models.py index ca5f155..b1f931e 100644 --- a/app/analysis_models.py +++ b/app/analysis_models.py @@ -323,3 +323,155 @@ class TradeImportBatch(AnalysisBase): def __repr__(self): return f"" + + +# ==================== 交易反思系统模型 ==================== + +class TradePair(AnalysisBase): + """交易配对表 - 将开仓和平仓配对为一个完整交易""" + __tablename__ = "trade_pairs" + + id = Column(Integer, primary_key=True, autoincrement=True) + variety = Column(String(16), nullable=False, index=True, comment="品种代码 AG") + direction = Column(String(8), nullable=False, comment="方向: long/short") + open_record_ids = Column(JSON, nullable=False, comment="开仓记录ID列表 [id1, id2, ...]") + close_record_ids = Column(JSON, nullable=False, comment="平仓记录ID列表 [id1, id2, ...]") + open_price = Column(Float, nullable=True, comment="平均开仓价") + close_price = Column(Float, nullable=True, comment="平均平仓价") + total_volume = Column(Float, nullable=True, comment="总手数") + close_pnl = Column(Float, nullable=True, default=0.0, comment="平仓盈亏") + total_commission = Column(Float, nullable=True, default=0.0, comment="总手续费") + net_pnl = Column(Float, nullable=True, default=0.0, comment="净盈亏 = 平仓盈亏 - 手续费") + open_date = Column(String(16), nullable=True, index=True, comment="开仓日期") + close_date = Column(String(16), nullable=True, index=True, comment="平仓日期") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + __table_args__ = ( + Index('ix_trade_pairs_variety_date', 'variety', 'open_date'), + ) + + def __repr__(self): + return f"" + + +class TradeReflection(AnalysisBase): + """交易反思表 - 每笔交易的反思内容""" + __tablename__ = "trade_reflections" + + id = Column(Integer, primary_key=True, autoincrement=True) + trade_pair_id = Column(Integer, nullable=False, index=True, comment="关联交易配对ID") + trade_date = Column(String(16), nullable=False, index=True, comment="交易日期") + # 模板字段 + entry_reason = Column(Text, nullable=True, comment="入场理由") + entry_timing = Column(String(8), nullable=True, comment="入场时机评价: good/fair/poor") + position_management = Column(Text, nullable=True, comment="仓位管理反思") + exit_reason = Column(Text, nullable=True, comment="出场理由") + exit_timing = Column(String(8), nullable=True, comment="出场时机评价: good/fair/poor") + discipline_score = Column(Integer, nullable=True, comment="纪律评分 1-5") + # 自由反思 + free_reflection = Column(Text, nullable=True, comment="自由反思内容") + # 状态 + ai_analyzed = Column(Boolean, nullable=False, default=False, comment="是否已AI分析") + ai_version = Column(Integer, nullable=True, comment="AI分析版本号") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class DailyReflection(AnalysisBase): + """当日反思表 - 每天的整体反思""" + __tablename__ = "daily_reflections" + + id = Column(Integer, primary_key=True, autoincrement=True) + reflection_date = Column(String(16), nullable=False, unique=True, index=True, comment="反思日期 YYYY-MM-DD") + emotion_state = Column(String(16), nullable=True, comment="情绪状态: 平静/兴奋/焦虑/恐惧/贪婪") + market_judgment = Column(Text, nullable=True, comment="市场判断") + discipline_score = Column(Integer, nullable=True, comment="执行纪律评分 1-5") + overall_rating = Column(Integer, nullable=True, comment="总体评价 1-5") + summary = Column(Text, nullable=True, comment="总结") + improvements = Column(Text, nullable=True, comment="改进方向") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class TradeTag(AnalysisBase): + """标签定义表""" + __tablename__ = "trade_tags" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(32), nullable=False, unique=True, index=True, comment="标签名称") + category = Column(String(16), nullable=False, comment="分类: operation/mindset/discipline/technical/custom") + is_preset = Column(Boolean, nullable=False, default=False, comment="是否预设标签") + color = Column(String(16), nullable=True, comment="标签颜色(前端用)") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + def __repr__(self): + return f"" + + +class TradeRecordTag(AnalysisBase): + """交易-标签关联表""" + __tablename__ = "trade_record_tags" + + id = Column(Integer, primary_key=True, autoincrement=True) + trade_pair_id = Column(Integer, nullable=False, index=True, comment="交易配对ID") + tag_id = Column(Integer, nullable=False, index=True, comment="标签ID") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + __table_args__ = ( + UniqueConstraint("trade_pair_id", "tag_id", name="uq_pair_tag"), + ) + + def __repr__(self): + return f"" + + +class TradeExperience(AnalysisBase): + """经验教训表""" + __tablename__ = "trade_experiences" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(128), nullable=False, comment="经验标题") + content = Column(Text, nullable=False, comment="经验内容") + exp_type = Column(String(16), nullable=False, comment="类型: lesson/tip/warning") + source_pair_id = Column(Integer, nullable=True, index=True, comment="来源交易配对ID") + source_date = Column(String(16), nullable=True, index=True, comment="来源交易日期") + tags = Column(JSON, nullable=True, comment="关联标签名称列表") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class TradeAIAnalysis(AnalysisBase): + """交易 AI 分析结果表(多版本)""" + __tablename__ = "trade_ai_analysis" + + id = Column(Integer, primary_key=True, autoincrement=True) + trade_pair_id = Column(Integer, nullable=False, index=True, comment="交易配对ID") + version = Column(Integer, nullable=False, comment="分析版本号") + # AI 输出结构化字段 + overall_evaluation = Column(Text, nullable=True, comment="综合评价") + strengths = Column(JSON, nullable=True, comment="优点列表") + weaknesses = Column(JSON, nullable=True, comment="不足列表") + experience_suggestion = Column(Text, nullable=True, comment="经验提炼建议") + suggestion_type = Column(String(16), nullable=True, comment="建议类型: lesson/tip/warning") + raw_response = Column(Text, nullable=True, comment="AI 原始响应") + prompt_snapshot = Column(Text, nullable=True, comment="提示词快照") + # 是否已保存到经验库 + saved_to_experience = Column(Boolean, nullable=False, default=False, comment="是否已保存到经验库") + experience_id = Column(Integer, nullable=True, comment="关联经验ID") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + __table_args__ = ( + Index('ix_trade_ai_analysis_pair_version', 'trade_pair_id', 'version', unique=True), + ) + + def __repr__(self): + return f"" diff --git a/app/api/trade_review.py b/app/api/trade_review.py index a5a9917..0a2408d 100644 --- a/app/api/trade_review.py +++ b/app/api/trade_review.py @@ -18,6 +18,18 @@ from app.services.trade_parser import ( calc_overall_statistics, get_trade_pairs, ) +from app.services.trade_pairing_engine import ( + auto_pair_trades, + get_daily_trades_with_pairs, + create_manual_pair, + delete_pair, +) +from app.services.reflection_ai_analysis import ( + analyze_with_reflection, + get_analysis_history, + get_latest_analysis, + save_suggestion_as_experience, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/trade-review", tags=["交易复盘"]) @@ -588,3 +600,538 @@ async def analyze_trade( except Exception as e: logger.exception("AI分析交易失败") return {"success": False, "message": f"AI分析失败: {str(e)}"} + + +# ==================== 交易反思系统 API ==================== + +from app.analysis_models import ( + TradePair, TradeReflection, DailyReflection, + TradeTag, TradeRecordTag, TradeExperience, +) +from pydantic import BaseModel + + +# --- 按天交易视图 --- + +@router.get("/daily-trades/{trade_date}") +def api_get_daily_trades( + trade_date: str, + db: Session = Depends(get_analysis_db), +): + """获取指定日期的交易+配对+反思状态""" + # 先执行自动配对 + auto_pair_trades(db, trade_date) + db.commit() + + data = get_daily_trades_with_pairs(db, trade_date) + return {"success": True, "data": data} + + +# --- 手动配对 --- + +class ManualPairRequest(BaseModel): + open_record_ids: list[int] + close_record_ids: list[int] + + +@router.post("/trade-pairs") +def api_create_manual_pair( + req: ManualPairRequest, + db: Session = Depends(get_analysis_db), +): + """手动创建交易配对""" + try: + pair = create_manual_pair(db, req.open_record_ids, req.close_record_ids) + db.commit() + return {"success": True, "data": {"id": pair.id}} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + logger.exception("创建配对失败") + raise HTTPException(status_code=500, detail=f"创建配对失败: {str(e)}") + + +@router.delete("/trade-pairs/{pair_id}") +def api_delete_pair( + pair_id: int, + db: Session = Depends(get_analysis_db), +): + """删除交易配对""" + success = delete_pair(db, pair_id) + if not success: + raise HTTPException(status_code=404, detail="配对不存在") + db.commit() + return {"success": True, "message": "配对已删除"} + + +# --- 反思 CRUD --- + +class ReflectionRequest(BaseModel): + trade_pair_id: int + trade_date: str + entry_reason: Optional[str] = None + entry_timing: Optional[str] = None + position_management: Optional[str] = None + exit_reason: Optional[str] = None + exit_timing: Optional[str] = None + discipline_score: Optional[int] = None + free_reflection: Optional[str] = None + + +@router.post("/reflections") +def api_create_reflection( + req: ReflectionRequest, + db: Session = Depends(get_analysis_db), +): + """创建交易反思""" + # 检查是否已存在 + existing = db.query(TradeReflection).filter( + TradeReflection.trade_pair_id == req.trade_pair_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="该交易已有反思,请使用更新接口") + + reflection = TradeReflection( + trade_pair_id=req.trade_pair_id, + trade_date=req.trade_date, + entry_reason=req.entry_reason, + entry_timing=req.entry_timing, + position_management=req.position_management, + exit_reason=req.exit_reason, + exit_timing=req.exit_timing, + discipline_score=req.discipline_score, + free_reflection=req.free_reflection, + ) + db.add(reflection) + db.commit() + return {"success": True, "data": {"id": reflection.id}} + + +@router.put("/reflections/{reflection_id}") +def api_update_reflection( + reflection_id: int, + req: ReflectionRequest, + db: Session = Depends(get_analysis_db), +): + """更新交易反思""" + reflection = db.query(TradeReflection).filter(TradeReflection.id == reflection_id).first() + if not reflection: + raise HTTPException(status_code=404, detail="反思不存在") + + # 更新字段 + for field in ['entry_reason', 'entry_timing', 'position_management', + 'exit_reason', 'exit_timing', 'discipline_score', 'free_reflection']: + val = getattr(req, field) + if val is not None: + setattr(reflection, field, val) + + # 重新保存时重置 AI 分析状态(因为内容变了) + reflection.ai_analyzed = False + reflection.ai_version = None + + db.commit() + return {"success": True, "message": "反思已更新"} + + +@router.get("/reflections") +def api_get_reflections( + trade_pair_id: Optional[int] = Query(None), + trade_date: Optional[str] = Query(None), + db: Session = Depends(get_analysis_db), +): + """查询反思列表""" + query = db.query(TradeReflection) + if trade_pair_id: + query = query.filter(TradeReflection.trade_pair_id == trade_pair_id) + if trade_date: + query = query.filter(TradeReflection.trade_date == trade_date) + + reflections = query.order_by(TradeReflection.created_at.desc()).all() + return {"success": True, "data": [{ + "id": r.id, + "trade_pair_id": r.trade_pair_id, + "trade_date": r.trade_date, + "entry_reason": r.entry_reason, + "entry_timing": r.entry_timing, + "position_management": r.position_management, + "exit_reason": r.exit_reason, + "exit_timing": r.exit_timing, + "discipline_score": r.discipline_score, + "free_reflection": r.free_reflection, + "ai_analyzed": r.ai_analyzed, + "ai_version": r.ai_version, + "created_at": r.created_at.strftime('%Y-%m-%d %H:%M:%S') if r.created_at else None, + "updated_at": r.updated_at.strftime('%Y-%m-%d %H:%M:%S') if r.updated_at else None, + } for r in reflections]} + + +# --- 当日反思 CRUD --- + +class DailyReflectionRequest(BaseModel): + reflection_date: str + emotion_state: Optional[str] = None + market_judgment: Optional[str] = None + discipline_score: Optional[int] = None + overall_rating: Optional[int] = None + summary: Optional[str] = None + improvements: Optional[str] = None + + +@router.post("/daily-reflections") +def api_create_daily_reflection( + req: DailyReflectionRequest, + db: Session = Depends(get_analysis_db), +): + """创建或更新当日反思""" + existing = db.query(DailyReflection).filter( + DailyReflection.reflection_date == req.reflection_date + ).first() + + if existing: + # 更新 + for field in ['emotion_state', 'market_judgment', 'discipline_score', + 'overall_rating', 'summary', 'improvements']: + val = getattr(req, field) + if val is not None: + setattr(existing, field, val) + db.commit() + return {"success": True, "data": {"id": existing.id, "action": "updated"}} + else: + # 创建 + dr = DailyReflection( + reflection_date=req.reflection_date, + emotion_state=req.emotion_state, + market_judgment=req.market_judgment, + discipline_score=req.discipline_score, + overall_rating=req.overall_rating, + summary=req.summary, + improvements=req.improvements, + ) + db.add(dr) + db.commit() + return {"success": True, "data": {"id": dr.id, "action": "created"}} + + +@router.get("/daily-reflections/{reflection_date}") +def api_get_daily_reflection( + reflection_date: str, + db: Session = Depends(get_analysis_db), +): + """获取当日反思""" + dr = db.query(DailyReflection).filter( + DailyReflection.reflection_date == reflection_date + ).first() + + if not dr: + return {"success": True, "data": None} + + return {"success": True, "data": { + "id": dr.id, + "reflection_date": dr.reflection_date, + "emotion_state": dr.emotion_state, + "market_judgment": dr.market_judgment, + "discipline_score": dr.discipline_score, + "overall_rating": dr.overall_rating, + "summary": dr.summary, + "improvements": dr.improvements, + }} + + +# --- 标签系统 --- + +class TagRequest(BaseModel): + name: str + category: str = "custom" + + +@router.get("/tags") +def api_get_tags( + category: Optional[str] = Query(None), + db: Session = Depends(get_analysis_db), +): + """获取所有标签""" + query = db.query(TradeTag) + if category: + query = query.filter(TradeTag.category == category) + + tags = query.order_by(TradeTag.is_preset.desc(), TradeTag.name).all() + return {"success": True, "data": [{ + "id": t.id, + "name": t.name, + "category": t.category, + "is_preset": t.is_preset, + } for t in tags]} + + +@router.post("/tags") +def api_create_tag( + req: TagRequest, + db: Session = Depends(get_analysis_db), +): + """创建自定义标签(含去重)""" + existing = db.query(TradeTag).filter(TradeTag.name == req.name).first() + if existing: + return {"success": True, "data": {"id": existing.id, "action": "exists"}} + + tag = TradeTag(name=req.name, category=req.category, is_preset=False) + db.add(tag) + db.commit() + return {"success": True, "data": {"id": tag.id, "action": "created"}} + + +@router.post("/trade-pairs/{pair_id}/tags") +def api_add_tag_to_trade( + pair_id: int, + tag_id: int, + db: Session = Depends(get_analysis_db), +): + """给交易打标签""" + # 检查是否已存在 + existing = db.query(TradeRecordTag).filter( + TradeRecordTag.trade_pair_id == pair_id, + TradeRecordTag.tag_id == tag_id, + ).first() + if existing: + return {"success": True, "message": "标签已存在"} + + relation = TradeRecordTag(trade_pair_id=pair_id, tag_id=tag_id) + db.add(relation) + db.commit() + return {"success": True, "message": "标签已添加"} + + +@router.delete("/trade-pairs/{pair_id}/tags/{tag_id}") +def api_remove_tag_from_trade( + pair_id: int, + tag_id: int, + db: Session = Depends(get_analysis_db), +): + """移除交易标签""" + db.query(TradeRecordTag).filter( + TradeRecordTag.trade_pair_id == pair_id, + TradeRecordTag.tag_id == tag_id, + ).delete() + db.commit() + return {"success": True, "message": "标签已移除"} + + +@router.get("/trade-pairs/{pair_id}/tags") +def api_get_trade_tags( + pair_id: int, + db: Session = Depends(get_analysis_db), +): + """获取交易的标签列表""" + relations = db.query(TradeRecordTag).filter( + TradeRecordTag.trade_pair_id == pair_id + ).all() + + tag_ids = [r.tag_id for r in relations] + tags = db.query(TradeTag).filter(TradeTag.id.in_(tag_ids)).all() + + return {"success": True, "data": [{ + "id": t.id, + "name": t.name, + "category": t.category, + } for t in tags]} + + +# --- 经验库 --- + +class ExperienceRequest(BaseModel): + title: str + content: str + exp_type: str # lesson/tip/warning + source_pair_id: Optional[int] = None + source_date: Optional[str] = None + tags: Optional[list[str]] = None + + +@router.get("/experiences") +def api_get_experiences( + exp_type: Optional[str] = Query(None), + tag: Optional[str] = Query(None), + keyword: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_analysis_db), +): + """获取经验列表(支持筛选和分页)""" + query = db.query(TradeExperience) + if exp_type: + query = query.filter(TradeExperience.exp_type == exp_type) + if tag: + query = query.filter(TradeExperience.tags.contains([tag])) + if keyword: + query = query.filter( + (TradeExperience.title.contains(keyword)) | + (TradeExperience.content.contains(keyword)) + ) + + total = query.count() + experiences = query.order_by( + TradeExperience.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + + return {"success": True, "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [{ + "id": e.id, + "title": e.title, + "content": e.content, + "exp_type": e.exp_type, + "source_pair_id": e.source_pair_id, + "source_date": e.source_date, + "tags": e.tags or [], + "created_at": e.created_at.strftime('%Y-%m-%d %H:%M:%S') if e.created_at else None, + } for e in experiences], + }} + + +@router.post("/experiences") +def api_create_experience( + req: ExperienceRequest, + db: Session = Depends(get_analysis_db), +): + """创建经验""" + exp = TradeExperience( + title=req.title, + content=req.content, + exp_type=req.exp_type, + source_pair_id=req.source_pair_id, + source_date=req.source_date, + tags=req.tags or [], + ) + db.add(exp) + db.commit() + return {"success": True, "data": {"id": exp.id}} + + +@router.put("/experiences/{exp_id}") +def api_update_experience( + exp_id: int, + req: ExperienceRequest, + db: Session = Depends(get_analysis_db), +): + """更新经验""" + exp = db.query(TradeExperience).filter(TradeExperience.id == exp_id).first() + if not exp: + raise HTTPException(status_code=404, detail="经验不存在") + + for field in ['title', 'content', 'exp_type', 'source_pair_id', 'source_date', 'tags']: + val = getattr(req, field) + if val is not None: + setattr(exp, field, val) + + db.commit() + return {"success": True, "message": "经验已更新"} + + +@router.delete("/experiences/{exp_id}") +def api_delete_experience( + exp_id: int, + db: Session = Depends(get_analysis_db), +): + """删除经验""" + exp = db.query(TradeExperience).filter(TradeExperience.id == exp_id).first() + if not exp: + raise HTTPException(status_code=404, detail="经验不存在") + + db.delete(exp) + db.commit() + return {"success": True, "message": "经验已删除"} + + +@router.get("/experiences/tag-stats") +def api_get_experience_tag_stats( + db: Session = Depends(get_analysis_db), +): + """按标签统计经验数量""" + from collections import Counter + + experiences = db.query(TradeExperience).all() + tag_counter = Counter() + for exp in experiences: + for tag in (exp.tags or []): + tag_counter[tag] += 1 + + # 同时返回所有标签的定义信息 + tags = db.query(TradeTag).all() + tag_map = {t.name: {"id": t.id, "category": t.category, "is_preset": t.is_preset} for t in tags} + + data = [] + for name, count in tag_counter.most_common(): + info = tag_map.get(name, {}) + data.append({ + "name": name, + "count": count, + "tag_id": info.get("id"), + "category": info.get("category", "custom"), + "is_preset": info.get("is_preset", False), + }) + + return {"success": True, "data": data} + + +# ==================== AI 重新分析 ==================== + +class ReanalyzeRequest(BaseModel): + trade_pair_id: int + + +@router.post("/reanalyze") +async def api_reanalyze_trade( + req: ReanalyzeRequest, + db: Session = Depends(get_analysis_db), +): + """基于反思内容重新 AI 分析交易""" + result = analyze_with_reflection(db, req.trade_pair_id) + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("message", "分析失败")) + return result + + +@router.get("/reanalyze/{trade_pair_id}/history") +def api_get_analysis_history( + trade_pair_id: int, + db: Session = Depends(get_analysis_db), +): + """获取交易的 AI 分析历史版本""" + history = get_analysis_history(db, trade_pair_id) + return {"success": True, "data": history} + + +@router.get("/reanalyze/{trade_pair_id}/latest") +def api_get_latest_analysis( + trade_pair_id: int, + db: Session = Depends(get_analysis_db), +): + """获取交易最新 AI 分析结果""" + analysis = get_latest_analysis(db, trade_pair_id) + return {"success": True, "data": analysis} + + +class SaveSuggestionRequest(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + tags: Optional[list[str]] = None + + +@router.post("/ai-analysis/{analysis_id}/save-experience") +def api_save_suggestion_as_experience( + analysis_id: int, + req: SaveSuggestionRequest, + db: Session = Depends(get_analysis_db), +): + """将 AI 分析建议保存到经验库""" + result = save_suggestion_as_experience( + db, analysis_id, + title=req.title, + content=req.content, + tags=req.tags, + ) + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("message", "保存失败")) + return result diff --git a/app/services/reflection_ai_analysis.py b/app/services/reflection_ai_analysis.py new file mode 100644 index 0000000..c1363d0 --- /dev/null +++ b/app/services/reflection_ai_analysis.py @@ -0,0 +1,335 @@ +""" +交易反思 AI 分析服务 - 基于反思内容重新分析交易并提炼经验 +""" +import json +import logging +import re +from typing import Dict, List, Optional +from sqlalchemy.orm import Session + +from app.analysis_models import TradePair, TradeReflection, TradeAIAnalysis, TradeExperience +from app.services.ai_analysis import AIFuturesAnalyzer + +logger = logging.getLogger(__name__) + + +REFLECTION_ANALYSIS_PROMPT = """你是一位资深的交易心理与策略复盘教练。请基于以下交易数据和交易者的反思内容,进行深度分析。 + +=== 交易基础信息 === +品种:{variety} +方向:{direction_text} +开仓日期时间:{open_date} {open_time} +平仓日期时间:{close_date} {close_time} +开仓均价:{open_price} +平仓均价:{close_price} +交易手数:{volume} +平仓盈亏:{close_pnl} +手续费:{commission} +净盈亏:{net_pnl} + +=== 交易者反思内容 === +入场理由:{entry_reason} +入场时机评价:{entry_timing} +仓位管理反思:{position_management} +出场理由:{exit_reason} +出场时机评价:{exit_timing} +纪律评分(1-5):{discipline_score} +自由反思:{free_reflection} + +=== 当日整体反思 === +情绪状态:{emotion_state} +市场判断:{market_judgment} +当日总结:{daily_summary} +改进方向:{daily_improvements} + +=== 任务要求 === +请严格按以下 JSON 格式输出分析结果(不要输出任何其他内容): + +{{ + "overall_evaluation": "综合评价:这笔交易的执行质量、决策逻辑、与市场的匹配度等", + "strengths": ["优点1", "优点2", "优点3"], + "weaknesses": ["不足1", "不足2", "不足3"], + "experience_suggestion": "提炼出的核心经验教训或改进建议,语言简洁 actionable", + "suggestion_type": "lesson", + "actionable_plan": ["下次可以做的具体改进1", "下次可以做的具体改进2"] +}} + +注意: +1. suggestion_type 只能是 lesson(经验)、tip(技巧)、warning(警告)三者之一 +2. 必须结合反思内容进行分析,不要脱离交易者自己的总结 +3. 如果交易亏损,重点分析亏损原因和避免方案 +4. 如果交易盈利,重点分析盈利是否来自计划还是运气 +""" + + +def _timing_text(timing: Optional[str]) -> str: + """时机评价转中文""" + mapping = {"good": "良好", "fair": "一般", "poor": "较差"} + return mapping.get(timing, timing or "未评价") + + +def _direction_text(direction: Optional[str]) -> str: + """方向转中文""" + mapping = {"long": "多头", "short": "空头"} + return mapping.get(direction, direction or "未知") + + +def _get_records_text(db: Session, pair: TradePair) -> str: + """获取配对关联的原始记录信息""" + from app.analysis_models import TradeRecord + record_ids = (pair.open_record_ids or []) + (pair.close_record_ids or []) + if not record_ids: + return "" + records = db.query(TradeRecord).filter(TradeRecord.id.in_(record_ids)).all() + lines = [] + for r in sorted(records, key=lambda x: f"{x.trade_date} {x.trade_time or ''}"): + lines.append(f"{r.trade_date} {r.trade_time or ''} {r.symbol} {r.direction}{r.offset} {r.price}×{r.volume}") + return "\n".join(lines) + + +def build_reflection_prompt(db: Session, pair: TradePair, reflection: TradeReflection, + daily_reflection: Optional[Dict] = None) -> str: + """构建反思增强分析提示词""" + daily = daily_reflection or {} + + # 获取第一条开仓和第一条平仓记录的时间 + from app.analysis_models import TradeRecord + open_time = "" + close_time = "" + open_record_ids = pair.open_record_ids or [] + close_record_ids = pair.close_record_ids or [] + if open_record_ids: + open_rec = db.query(TradeRecord).filter(TradeRecord.id == open_record_ids[0]).first() + if open_rec: + open_time = open_rec.trade_time or "" + if close_record_ids: + close_rec = db.query(TradeRecord).filter(TradeRecord.id == close_record_ids[0]).first() + if close_rec: + close_time = close_rec.trade_time or "" + + return REFLECTION_ANALYSIS_PROMPT.format( + variety=pair.variety, + direction_text=_direction_text(pair.direction), + open_date=pair.open_date or "", + open_time=open_time, + close_date=pair.close_date or "", + close_time=close_time, + open_price=pair.open_price, + close_price=pair.close_price, + volume=pair.total_volume, + close_pnl=pair.close_pnl, + commission=pair.total_commission, + net_pnl=pair.net_pnl, + entry_reason=reflection.entry_reason or "未填写", + entry_timing=_timing_text(reflection.entry_timing), + position_management=reflection.position_management or "未填写", + exit_reason=reflection.exit_reason or "未填写", + exit_timing=_timing_text(reflection.exit_timing), + discipline_score=reflection.discipline_score or "未评分", + free_reflection=reflection.free_reflection or "未填写", + emotion_state=daily.get("emotion_state", "未填写"), + market_judgment=daily.get("market_judgment", "未填写"), + daily_summary=daily.get("summary", "未填写"), + daily_improvements=daily.get("improvements", "未填写"), + ) + + +def _parse_ai_response(response: str) -> Dict: + """解析 AI 返回的 JSON""" + try: + # 尝试直接解析 + data = json.loads(response) + return data + except json.JSONDecodeError: + pass + + # 尝试从 Markdown 代码块中提取 + code_block_pattern = re.compile(r'```(?:json)?\s*([\s\S]*?)```', re.MULTILINE) + matches = code_block_pattern.findall(response) + for match in matches: + try: + return json.loads(match.strip()) + except json.JSONDecodeError: + continue + + # 尝试从文本中提取第一个 JSON 对象 + json_pattern = re.compile(r'\{[\s\S]*\}') + matches = json_pattern.findall(response) + for match in matches: + try: + return json.loads(match) + except json.JSONDecodeError: + continue + + # 解析失败,返回原始内容作为 overall_evaluation + return { + "overall_evaluation": response, + "strengths": [], + "weaknesses": [], + "experience_suggestion": "", + "suggestion_type": "lesson", + "actionable_plan": [], + } + + +def _get_next_version(db: Session, trade_pair_id: int) -> int: + """获取下一个版本号""" + latest = db.query(TradeAIAnalysis).filter( + TradeAIAnalysis.trade_pair_id == trade_pair_id + ).order_by(TradeAIAnalysis.version.desc()).first() + return (latest.version + 1) if latest else 1 + + +def analyze_with_reflection(db: Session, trade_pair_id: int) -> Dict: + """ + 基于反思内容重新分析交易 + 返回:{success, data/version, message} + """ + pair = db.query(TradePair).filter(TradePair.id == trade_pair_id).first() + if not pair: + return {"success": False, "message": "交易配对不存在"} + + reflection = db.query(TradeReflection).filter( + TradeReflection.trade_pair_id == trade_pair_id + ).first() + if not reflection: + return {"success": False, "message": "该交易尚未填写反思,无法进行分析"} + + # 获取当日反思 + daily_reflection = None + from app.analysis_models import DailyReflection + dr = db.query(DailyReflection).filter( + DailyReflection.reflection_date == pair.close_date + ).first() + if dr: + daily_reflection = { + "emotion_state": dr.emotion_state, + "market_judgment": dr.market_judgment, + "summary": dr.summary, + "improvements": dr.improvements, + } + + # 构建提示词 + prompt = build_reflection_prompt(db, pair, reflection, daily_reflection) + + # 调用 AI + analyzer = AIFuturesAnalyzer(db) + model = analyzer.get_active_model() + if not model: + return {"success": False, "message": "未配置AI模型或模型未激活"} + + response = analyzer.call_ai_model(prompt, model) + if not response: + return {"success": False, "message": "AI模型返回空响应,请稍后重试"} + + # 解析结果 + parsed = _parse_ai_response(response) + + # 计算版本号 + version = _get_next_version(db, trade_pair_id) + + # 保存分析结果 + analysis = TradeAIAnalysis( + trade_pair_id=trade_pair_id, + version=version, + overall_evaluation=parsed.get("overall_evaluation", ""), + strengths=parsed.get("strengths", []), + weaknesses=parsed.get("weaknesses", []), + experience_suggestion=parsed.get("experience_suggestion", ""), + suggestion_type=parsed.get("suggestion_type", "lesson"), + raw_response=response, + prompt_snapshot=prompt, + ) + db.add(analysis) + + # 更新反思的 AI 分析状态 + reflection.ai_analyzed = True + reflection.ai_version = version + + db.commit() + + return { + "success": True, + "data": { + "version": version, + "overall_evaluation": analysis.overall_evaluation, + "strengths": analysis.strengths, + "weaknesses": analysis.weaknesses, + "experience_suggestion": analysis.experience_suggestion, + "suggestion_type": analysis.suggestion_type, + "actionable_plan": parsed.get("actionable_plan", []), + }, + } + + +def get_analysis_history(db: Session, trade_pair_id: int) -> List[Dict]: + """获取交易的所有 AI 分析版本""" + analyses = db.query(TradeAIAnalysis).filter( + TradeAIAnalysis.trade_pair_id == trade_pair_id + ).order_by(TradeAIAnalysis.version.desc()).all() + + return [{ + "id": a.id, + "version": a.version, + "overall_evaluation": a.overall_evaluation, + "strengths": a.strengths, + "weaknesses": a.weaknesses, + "experience_suggestion": a.experience_suggestion, + "suggestion_type": a.suggestion_type, + "saved_to_experience": a.saved_to_experience, + "experience_id": a.experience_id, + "created_at": a.created_at.strftime('%Y-%m-%d %H:%M:%S') if a.created_at else None, + } for a in analyses] + + +def get_latest_analysis(db: Session, trade_pair_id: int) -> Optional[Dict]: + """获取最新 AI 分析结果""" + analysis = db.query(TradeAIAnalysis).filter( + TradeAIAnalysis.trade_pair_id == trade_pair_id + ).order_by(TradeAIAnalysis.version.desc()).first() + + if not analysis: + return None + + return { + "id": analysis.id, + "version": analysis.version, + "overall_evaluation": analysis.overall_evaluation, + "strengths": analysis.strengths, + "weaknesses": analysis.weaknesses, + "experience_suggestion": analysis.experience_suggestion, + "suggestion_type": analysis.suggestion_type, + "saved_to_experience": analysis.saved_to_experience, + "experience_id": analysis.experience_id, + "created_at": analysis.created_at.strftime('%Y-%m-%d %H:%M:%S') if analysis.created_at else None, + } + + +def save_suggestion_as_experience(db: Session, analysis_id: int, title: Optional[str] = None, + content: Optional[str] = None, tags: Optional[List[str]] = None) -> Dict: + """将 AI 分析建议保存为经验""" + analysis = db.query(TradeAIAnalysis).filter(TradeAIAnalysis.id == analysis_id).first() + if not analysis: + return {"success": False, "message": "分析记录不存在"} + + pair = db.query(TradePair).filter(TradePair.id == analysis.trade_pair_id).first() + + exp_title = title or analysis.experience_suggestion[:50] if analysis.experience_suggestion else "AI提炼经验" + exp_content = content or analysis.experience_suggestion or "" + + exp = TradeExperience( + title=exp_title, + content=exp_content, + exp_type=analysis.suggestion_type or "lesson", + source_pair_id=analysis.trade_pair_id, + source_date=pair.close_date if pair else None, + tags=tags or [], + ) + db.add(exp) + db.flush() + + analysis.saved_to_experience = True + analysis.experience_id = exp.id + db.commit() + + return {"success": True, "data": {"experience_id": exp.id}} diff --git a/app/services/trade_pairing_engine.py b/app/services/trade_pairing_engine.py new file mode 100644 index 0000000..900dc63 --- /dev/null +++ b/app/services/trade_pairing_engine.py @@ -0,0 +1,336 @@ +""" +交易配对引擎 - 将开仓和平仓配对为完整交易,持久化到 TradePair 表 +""" +import logging +from typing import Optional + +from sqlalchemy.orm import Session +from collections import defaultdict + +from app.analysis_models import TradeRecord, TradePair, TradeReflection, DailyReflection + +logger = logging.getLogger(__name__) + + +def auto_pair_trades(db: Session, trade_date: str) -> dict: + """ + 自动配对指定日期及之前未平仓的交易记录 + 按品种+方向分组,FIFO 规则配对 + 返回:{created: 新建配对数, updated: 更新配对数, unpaired: 未配对记录数} + """ + # 查询指定日期的记录 + current_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 current_records: + return {"created": 0, "updated": 0, "unpaired": 0} + + # 查询之前未配对的记录(跨日持仓) + # 找出所有已经配对的记录 ID + all_pairs = db.query(TradePair).all() + paired_ids = set() + for p in all_pairs: + paired_ids.update(p.open_record_ids or []) + paired_ids.update(p.close_record_ids or []) + + # 查询 trade_date 之前的未配对记录 + previous_unpaired = db.query(TradeRecord).filter( + TradeRecord.trade_date < trade_date, + TradeRecord.id.notin_(paired_ids) if paired_ids else True, + ).order_by( + TradeRecord.variety, TradeRecord.symbol, TradeRecord.trade_date, TradeRecord.trade_time + ).all() + + # 合并当前记录和之前未配对记录 + all_records = previous_unpaired + current_records + + # 按品种分组 + by_variety = defaultdict(list) + for r in all_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, + ).all() + for p in existing_pairs: + all_paired_open_ids.update(p.open_record_ids or []) + all_paired_close_ids.update(p.close_record_ids or []) + + 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 + # 开仓队列,每个元素是 (record, remaining_volume) + open_queue = [(r, r.volume or 0) for r in opens] + close_queue = [(r, r.volume or 0) for r in closes] + + while open_queue and close_queue: + close_rec, close_remaining = close_queue[0] + + # 从开仓队列中取足够手数来匹配这个平仓 + needed = close_remaining + used_opens = [] # (record, take_volume) + while needed > 0 and open_queue: + open_rec, open_remaining = open_queue[0] + take = min(open_remaining, needed) + used_opens.append((open_rec, take)) + needed -= take + if take >= open_remaining: + open_queue.pop(0) + else: + open_queue[0] = (open_rec, open_remaining - take) + + if not used_opens: + break + + # 实际匹配的手数 + matched_volume = close_remaining - needed + + # 更新平仓队列 + if matched_volume >= close_remaining: + close_queue.pop(0) + else: + close_queue[0] = (close_rec, close_remaining - matched_volume) + + # 计算均价(按手数加权) + open_volume_sum = sum(take for _, take in used_opens) + open_price = sum((r.price or 0) * take for r, take in used_opens) / open_volume_sum if open_volume_sum > 0 else 0 + close_price = close_rec.price or 0 + + # 计算盈亏和手续费 + # 平仓盈亏按比例分配到该平仓记录 + close_pnl = (close_rec.close_pnl or 0) * (matched_volume / close_rec.volume) if close_rec.volume else 0 + total_commission = sum((r.commission or 0) * (take / r.volume) for r, take in used_opens if r.volume) + \ + (close_rec.commission or 0) * (matched_volume / close_rec.volume) if close_rec.volume else 0 + net_pnl = close_pnl - total_commission + + # 最早开仓日期和最早平仓日期 + open_date = min(r.trade_date for r, _ in used_opens) + close_date = close_rec.trade_date + + pair = TradePair( + variety=variety, + direction=direction, + open_record_ids=[r.id for r, _ in used_opens], + close_record_ids=[close_rec.id], + open_price=round(open_price, 2), + close_price=round(close_price, 2), + total_volume=matched_volume, + close_pnl=round(close_pnl, 2), + total_commission=round(total_commission, 2), + net_pnl=round(net_pnl, 2), + open_date=open_date, + close_date=close_date, + ) + db.add(pair) + created += 1 + + 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 or []) + paired_record_ids.update(p.close_record_ids or []) + + 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 diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html index b36717c..e36e665 100644 --- a/app/static/futures_analysis.html +++ b/app/static/futures_analysis.html @@ -495,6 +495,7 @@ .tr-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; } .tr-toolbar-left, .tr-toolbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .tr-btn { padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; display: flex; align-items: center; gap: 8px; transition: all 0.2s; font-family: inherit; } + .tr-btn:disabled { opacity: 0.5; cursor: not-allowed; } .tr-btn-primary { background: var(--color-brand); color: #fff; } .tr-btn-primary:hover { background: #0066d6; box-shadow: 0 4px 10px rgba(0,122,255,0.3); } .tr-btn-secondary { background: #fff; color: var(--text-secondary); box-shadow: var(--shadow-sm); border: 1px solid rgba(0,0,0,0.05); } @@ -582,12 +583,146 @@ .tr-empty-icon { font-size: 56px; opacity: 0.3; } .tr-empty-text { font-size: 15px; color: var(--text-tertiary); } + /* ============================================ + 交易反思子系统样式 + ============================================ */ + .tr-subtabs { display: flex; gap: 8px; margin-bottom: 24px; background: rgba(255,255,255,0.6); padding: 4px; border-radius: 14px; backdrop-filter: blur(8px); width: fit-content; } + .tr-subtab { padding: 8px 20px; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: var(--text-secondary); transition: all 0.2s; font-family: inherit; } + .tr-subtab:hover { background: rgba(0,0,0,0.03); color: var(--text-primary); } + .tr-subtab.active { background: #fff; color: var(--color-brand); box-shadow: var(--shadow-sm); font-weight: 600; } + + .tr-tab-panel { display: none; } + .tr-tab-panel.active { display: block; } + + /* 交易反思 - 日期导航 */ + .tr-refl-datebar { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 24px; } + .tr-refl-datebtn { width: 36px; height: 36px; border-radius: 50%; border: none; background: #fff; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow-sm); transition: all 0.2s; font-size: 16px; } + .tr-refl-datebtn:hover { background: var(--color-brand); color: #fff; } + .tr-refl-datetext { font-size: 20px; font-weight: 700; color: var(--text-primary); min-width: 120px; text-align: center; } + .tr-refl-actions { display: flex; gap: 10px; margin-left: auto; } + + /* 反思统计卡片 */ + .tr-refl-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; } + .tr-refl-statcard { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; text-align: center; box-shadow: var(--shadow-sm); transition: transform 0.2s; } + .tr-refl-statcard:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } + .tr-refl-statlabel { font-size: 12px; color: var(--text-tertiary); margin-bottom: 8px; font-weight: 600; } + .tr-refl-statvalue { font-size: 26px; font-weight: 700; } + .tr-refl-statvalue.profit { color: var(--color-down); } + .tr-refl-statvalue.loss { color: var(--color-up); } + .tr-refl-reflected { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; } + .tr-refl-reflected.yes { background: rgba(52,199,89,0.12); color: var(--color-down); } + .tr-refl-reflected.no { background: rgba(255,59,48,0.12); color: var(--color-up); } + + /* 当日反思区域 */ + .tr-refl-daily { background: var(--bg-card); border-radius: var(--radius-card); padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-sm); } + .tr-refl-daily-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } + .tr-refl-daily-title { font-size: 17px; font-weight: 700; color: var(--text-primary); } + .tr-refl-daily-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } + .tr-refl-daily-item { background: #F5F5F7; border-radius: 12px; padding: 14px; } + .tr-refl-daily-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 6px; font-weight: 600; text-transform: uppercase; } + .tr-refl-daily-value { font-size: 14px; color: var(--text-primary); font-weight: 500; line-height: 1.4; } + .tr-refl-daily-value.empty { color: var(--text-tertiary); font-style: italic; } + + /* 交易配对卡片 */ + .tr-refl-section-title { font-size: 17px; font-weight: 700; margin-bottom: 16px; color: var(--text-primary); } + .tr-refl-pairs { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; margin-bottom: 24px; } + .tr-refl-card { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; box-shadow: var(--shadow-sm); transition: transform 0.2s, box-shadow 0.2s; position: relative; overflow: hidden; } + .tr-refl-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } + .tr-refl-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--color-brand); } + .tr-refl-card.profit::before { background: var(--color-down); } + .tr-refl-card.loss::before { background: var(--color-up); } + .tr-refl-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; } + .tr-refl-card-symbol { font-size: 16px; font-weight: 700; } + .tr-refl-card-dir { font-size: 12px; padding: 3px 10px; border-radius: 6px; font-weight: 600; } + .tr-refl-card-dir.long { background: rgba(52,199,89,0.12); color: var(--color-down); } + .tr-refl-card-dir.short { background: rgba(255,59,48,0.12); color: var(--color-up); } + .tr-refl-card-pnl { font-size: 20px; font-weight: 700; text-align: right; } + .tr-refl-card-pnl.profit { color: var(--color-down); } + .tr-refl-card-pnl.loss { color: var(--color-up); } + .tr-refl-card-nodes { font-size: 13px; color: var(--text-secondary); margin-bottom: 14px; line-height: 1.6; } + .tr-refl-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } + .tr-refl-tag { font-size: 11px; padding: 4px 10px; border-radius: 9999px; background: #F5F5F7; color: var(--text-secondary); font-weight: 500; } + .tr-refl-tag.active { background: rgba(0,122,255,0.12); color: var(--color-brand); } + .tr-refl-card-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .tr-refl-card-actions .tr-btn { padding: 6px 12px; font-size: 12px; } + + /* 未配对交易 */ + .tr-refl-unpaired { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; box-shadow: var(--shadow-sm); } + .tr-refl-unpaired-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } + .tr-refl-unpaired-header .tr-refl-section-title { margin-bottom: 0; } + .tr-refl-unpaired-table { width: 100%; border-collapse: collapse; font-size: 13px; } + .tr-refl-unpaired-table th { background: #F5F5F7; padding: 10px 8px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; } + .tr-refl-unpaired-table td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); } + .tr-refl-unpaired-table input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-brand); cursor: pointer; } + + /* 经验库 */ + .tr-exp-toolbar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; } + .tr-exp-search { background: #fff; border: 1px solid rgba(0,0,0,0.05); border-radius: 12px; padding: 0 14px; height: 40px; font-size: 14px; width: 260px; outline: none; } + .tr-exp-search:focus { border-color: var(--color-brand); box-shadow: 0 0 0 3px rgba(0,122,255,0.15); } + .tr-exp-filter { background: #fff; border: 1px solid rgba(0,0,0,0.05); border-radius: 10px; padding: 0 12px; height: 36px; font-size: 13px; color: var(--text-primary); outline: none; } + .tr-exp-viewtabs { display: flex; gap: 6px; margin-left: auto; } + .tr-exp-viewtab { padding: 6px 14px; border-radius: 10px; font-size: 13px; cursor: pointer; border: none; background: #fff; color: var(--text-secondary); transition: all 0.2s; } + .tr-exp-viewtab:hover { background: #F5F5F7; } + .tr-exp-viewtab.active { background: var(--color-brand); color: #fff; } + .tr-exp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } + .tr-exp-card { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; box-shadow: var(--shadow-sm); transition: all 0.2s; } + .tr-exp-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } + .tr-exp-card-title { font-size: 15px; font-weight: 700; margin-bottom: 10px; color: var(--text-primary); } + .tr-exp-card-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px; } + .tr-exp-card-content { font-size: 13px; color: var(--text-secondary); line-height: 1.7; margin-bottom: 14px; } + .tr-exp-card-tags { display: flex; flex-wrap: wrap; gap: 6px; } + .tr-exp-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 24px; } + .tr-exp-pageinfo { font-size: 13px; color: var(--text-secondary); font-weight: 500; } + .tr-exp-detail-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 16px; } + .tr-exp-detail-content { font-size: 14px; color: var(--text-primary); line-height: 1.8; background: #F5F5F7; border-radius: 12px; padding: 16px; margin-bottom: 16px; white-space: pre-wrap; } + .tr-exp-detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; } + .tr-exp-detail-source { font-size: 13px; color: var(--text-secondary); } + .tr-exp-detail-source .link { cursor: pointer; } + + /* Modal 表单 */ + .tr-form-group { margin-bottom: 18px; } + .tr-form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; } + .tr-form-input, .tr-form-select, .tr-form-textarea { width: 100%; background: #F5F5F7; border: 1px solid transparent; border-radius: 10px; padding: 10px 12px; font-size: 14px; color: var(--text-primary); font-family: inherit; outline: none; transition: all 0.2s; } + .tr-form-input:focus, .tr-form-select:focus, .tr-form-textarea:focus { border-color: var(--color-brand); background: #fff; box-shadow: 0 0 0 3px rgba(0,122,255,0.1); } + .tr-form-textarea { min-height: 90px; resize: vertical; } + .tr-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + .tr-form-rating { display: flex; gap: 6px; } + .tr-form-star { font-size: 22px; color: #E5E5E7; cursor: pointer; transition: color 0.15s; } + .tr-form-star.active { color: #FF9F0A; } + .tr-form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; } + + /* 标签管理 */ + .tr-tag-section { margin-bottom: 18px; } + .tr-tag-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; } + .tr-tag-pool { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } + .tr-tag-chip { padding: 6px 12px; border-radius: 9999px; font-size: 12px; cursor: pointer; border: 1px solid rgba(0,0,0,0.06); background: #fff; color: var(--text-secondary); transition: all 0.15s; } + .tr-tag-chip:hover { border-color: var(--color-brand); color: var(--color-brand); } + .tr-tag-chip.selected { background: rgba(0,122,255,0.12); border-color: var(--color-brand); color: var(--color-brand); font-weight: 500; } + .tr-tag-custom { display: flex; gap: 8px; margin-top: 12px; } + .tr-tag-custom .tr-form-input { flex: 1; } + + /* AI 分析结果 */ + .tr-ai-version-row { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 12px 16px; background: #F5F5F7; border-radius: 12px; } + .tr-ai-version-label { font-size: 13px; font-weight: 600; color: var(--text-tertiary); } + .tr-ai-version-row .tr-form-select { flex: 1; min-width: 200px; margin: 0; } + .tr-ai-result-section { margin-bottom: 18px; } + .tr-ai-result-title { font-size: 14px; font-weight: 700; color: var(--color-brand); margin-bottom: 8px; } + .tr-ai-result-content { font-size: 14px; color: var(--text-primary); line-height: 1.8; background: #F5F5F7; border-radius: 12px; padding: 14px; } + .tr-ai-list { padding-left: 18px; margin: 0; } + .tr-ai-list li { margin-bottom: 6px; line-height: 1.6; } + @media (max-width: 1200px) { .tr-stats-grid { grid-template-columns: repeat(3, 1fr); } + .tr-refl-stats { grid-template-columns: repeat(2, 1fr); } + .tr-refl-daily-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 768px) { .tr-stats-grid { grid-template-columns: repeat(2, 1fr); } .tr-overview-grid { grid-template-columns: repeat(2, 1fr); } + .tr-refl-stats { grid-template-columns: 1fr; } + .tr-refl-daily-grid { grid-template-columns: 1fr; } + .tr-form-row { grid-template-columns: 1fr; } + .tr-refl-pairs { grid-template-columns: 1fr; } } @media (max-width: 768px) { @@ -974,82 +1109,429 @@
- -
-
- - - - - - ~ - - + +
+ + + +
+ + +
+ +
+
+ + + + + + ~ + + +
+
+ + +
-
- - + + +
+ + +
+
账户权益曲线(累计净盈亏 + 每日盈亏)
+
+
+ + +
+
品种盈亏分布(气泡大小=手续费占比,颜色=净盈亏)
+
+
+ + +
+
每日交易详情(点击日期查看详情)
+ + + + + + + + + +
日期笔数平仓盈亏手续费净盈亏盈利笔亏损笔胜率最大盈利最大亏损品种数操作
+ +
+ + +
+
品种盈亏排行(点击查看品种详情)
+ + + + + + + + + +
排名代码名称净盈亏平仓盈亏手续费胜率盈亏比笔数操作
+ +
+
+ + +
+
+ + - + +
+ +
+
+ +
+
+
总盈亏
+
-
+
+
+
交易笔数
+
-
+
+
+
胜率
+
-
+
+
+
已反思
+
-
+
+
+ +
+
+
当日反思
+
+
+
+
情绪状态
+
-
+
+
+
执行纪律
+
-
+
+
+
总体评价
+
-
+
+
+
市场判断
+
-
+
+
+
+ +
交易配对
+
+ +
+
+
未配对交易
+ +
+ + + + + +
时间品种开平方向价格手数盈亏
+
- -
+ +
+
+ + + +
+ + + +
+
+
+ + +
- -
-
账户权益曲线(累计净盈亏 + 每日盈亏)
-
+ +
+
+
+ 写反思 + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + + + + +
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+
- -
-
品种盈亏分布(气泡大小=手续费占比,颜色=净盈亏)
-
+ +
+
+
+ 当日反思编辑 + +
+
+
+
+
+ + +
+
+ +
+ + + + + +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
- -
-
每日交易详情(点击日期查看详情)
- - - - - - - - - -
日期笔数平仓盈亏手续费净盈亏盈利笔亏损笔胜率最大盈利最大亏损品种数
-