From 216a761243217b3154eef99f4e7d8913a60ca30d Mon Sep 17 00:00:00 2001 From: Jason-hou Date: Tue, 24 Mar 2026 11:17:22 +0800 Subject: [PATCH] add_trader_strategy_change --- app/agents/trader_reflection_agent.rb | 43 + app/assets/stylesheets/application.css | 1 + app/assets/stylesheets/trader_reflections.css | 1103 +++++++++++++++++ app/assets/stylesheets/traders.css | 17 + .../trader_reflections_controller.rb | 37 + app/controllers/traders_controller.rb | 25 + app/models/strategy_adjustment_log.rb | 8 + app/models/trader.rb | 1 + app/models/trader_reflection.rb | 13 + app/models/trading_strategy.rb | 2 + ...ly_trader_reflection_adjustment_service.rb | 85 ++ app/services/trader_reflection_service.rb | 320 +++++ app/views/trader_reflections/show.html.erb | 404 ++++++ app/views/traders/show.html.erb | 168 ++- config/routes.rb | 3 + config/sidekiq.yml | 31 +- ...0260324112512_create_trader_reflections.rb | 26 + ...4123000_create_strategy_adjustment_logs.rb | 16 + db/schema.rb | 60 +- docs/trader_reflection_report.md | 221 ++++ 20 files changed, 2562 insertions(+), 22 deletions(-) create mode 100644 app/agents/trader_reflection_agent.rb create mode 100644 app/assets/stylesheets/trader_reflections.css create mode 100644 app/controllers/trader_reflections_controller.rb create mode 100644 app/models/strategy_adjustment_log.rb create mode 100644 app/models/trader_reflection.rb create mode 100644 app/services/apply_trader_reflection_adjustment_service.rb create mode 100644 app/services/trader_reflection_service.rb create mode 100644 app/views/trader_reflections/show.html.erb create mode 100644 db/migrate/20260324112512_create_trader_reflections.rb create mode 100644 db/migrate/20260324123000_create_strategy_adjustment_logs.rb create mode 100644 docs/trader_reflection_report.md diff --git a/app/agents/trader_reflection_agent.rb b/app/agents/trader_reflection_agent.rb new file mode 100644 index 0000000..d623840 --- /dev/null +++ b/app/agents/trader_reflection_agent.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class TraderReflectionAgent < RubyLLM::Agent + model "gpt-5.2" + + instructions <<~PROMPT + 你是一位交易复盘与策略微调助手。请基于给定的 trader、策略、交易记录、执行结果、组合表现和当前持仓,生成一份结构化反思报告。 + + 目标: + 1. 总结最近一段时间的表现。 + 2. 识别策略执行中的优点、错误、模式和风险问题。 + 3. 只对有限参数给出微调建议,不要直接改写整套策略。 + + 可建议的参数只有: + - max_positions + - buy_signal_threshold + - max_position_size + - min_cash_reserve + + 输出要求: + - 必须严格返回 JSON + - 不要输出 markdown 代码块 + - 不要输出解释性前后文 + - 如果你认为无需调整参数,suggested_adjustments 返回空数组 + + 返回格式: + { + "summary": "string", + "strengths": ["string"], + "mistakes": ["string"], + "pattern_findings": ["string"], + "risk_issues": ["string"], + "suggested_adjustments": [ + { + "parameter": "max_positions|buy_signal_threshold|max_position_size|min_cash_reserve", + "direction": "increase|decrease|keep", + "reason": "string" + } + ], + "recommendation": "string" + } + PROMPT +end diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index da52d6f..11b1ee0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -19,3 +19,4 @@ @import "allocation_previews"; @import "allocation_decisions"; @import "job_executions"; +@import "trader_reflections"; diff --git a/app/assets/stylesheets/trader_reflections.css b/app/assets/stylesheets/trader_reflections.css new file mode 100644 index 0000000..b5473dc --- /dev/null +++ b/app/assets/stylesheets/trader_reflections.css @@ -0,0 +1,1103 @@ +/** + * SmartTrader Trader Reflection Report Styles + * A sophisticated, report-style design for AI-generated trading reflections + */ + +/* CSS Custom Properties for Reflection Report */ +:root { + --reflection-accent: #818cf8; + --reflection-accent-glow: rgba(129, 140, 248, 0.3); + --reflection-success: #34d399; + --reflection-warning: #fbbf24; + --reflection-danger: #f87171; + --reflection-info: #60a5fa; +} + +/* Report Container */ +.reflection-report { + min-height: 100vh; + background: var(--color-bg-primary); + color: var(--color-text-primary); + position: relative; + overflow-x: hidden; +} + +/* Animated Background with Subtle Gradient Mesh */ +.reflection-report__background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + +.reflection-report__gradient { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(ellipse at 20% 5%, rgba(129, 140, 248, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(244, 114, 182, 0.08) 0%, transparent 45%), + radial-gradient(ellipse at 40% 80%, rgba(34, 211, 238, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 70% 95%, rgba(129, 140, 248, 0.06) 0%, transparent 40%); + animation: gradientShift 20s ease-in-out infinite alternate; +} + +@keyframes gradientShift { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0.8; + transform: scale(1.1); + } +} + +.reflection-report__grid { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + linear-gradient(rgba(129, 140, 248, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(129, 140, 248, 0.025) 1px, transparent 1px); + background-size: 80px 80px; + mask-image: radial-gradient(ellipse at center, black 0%, transparent 70%); + -webkit-mask-image: radial-gradient(ellipse at center, black 0%, transparent 70%); +} + +.reflection-report__noise { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.015; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); +} + +/* Header */ +.reflection-report__header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2.5rem; + background: rgba(10, 14, 26, 0.85); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-bottom: 1px solid rgba(129, 140, 248, 0.1); +} + +.reflection-report__header-left { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.reflection-report__back-btn { + width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(129, 140, 248, 0.08); + border: 1px solid rgba(129, 140, 248, 0.15); + border-radius: 12px; + color: var(--reflection-accent); + transition: all 0.2s ease; +} + +.reflection-report__back-btn:hover { + background: rgba(129, 140, 248, 0.15); + border-color: rgba(129, 140, 248, 0.3); + transform: translateX(-2px); +} + +.reflection-report__back-btn svg { + width: 20px; + height: 20px; +} + +.reflection-report__title-block { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.reflection-report__eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--reflection-accent); + opacity: 0.9; +} + +.reflection-report__title { + font-size: 1.375rem; + font-weight: 700; + color: var(--color-text-primary); + letter-spacing: -0.02em; +} + +.reflection-report__header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Main Content */ +.reflection-report__main { + padding: 2.5rem; + max-width: 1200px; + margin: 0 auto; +} + +/* Hero Section - Trader Info & Period */ +.reflection-report__hero { + display: grid; + grid-template-columns: 1fr auto; + gap: 2rem; + align-items: start; + margin-bottom: 2rem; +} + +.reflection-report__hero-content { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.reflection-report__trader-name { + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(135deg, #fff 0%, rgba(129, 140, 248, 0.8) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.reflection-report__period { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1.25rem; + background: linear-gradient(135deg, rgba(129, 140, 248, 0.08) 0%, rgba(244, 114, 182, 0.05) 100%); + border: 1px solid rgba(129, 140, 248, 0.15); + border-radius: 100px; + width: fit-content; +} + +.reflection-report__period-icon { + width: 18px; + height: 18px; + color: var(--reflection-accent); +} + +.reflection-report__period-text { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.reflection-report__meta-grid { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.reflection-report__meta-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; +} + +.reflection-report__meta-chip-icon { + width: 14px; + height: 14px; + color: var(--color-text-muted); +} + +.reflection-report__meta-chip-label { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.reflection-report__meta-chip-value { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-primary); +} + +/* Metrics Hero Card */ +.reflection-report__metrics-card { + padding: 1.5rem; + background: linear-gradient(135deg, rgba(129, 140, 248, 0.06) 0%, rgba(244, 114, 182, 0.04) 100%); + border: 1px solid rgba(129, 140, 248, 0.12); + border-radius: 20px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + min-width: 280px; +} + +.reflection-report__metrics-title { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 1rem; +} + +.reflection-report__metrics-list { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.reflection-report__metric { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; +} + +.reflection-report__metric-label { + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +.reflection-report__metric-value { + font-size: 1.125rem; + font-weight: 700; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.reflection-report__metric-value.positive { + color: var(--reflection-success); +} + +.reflection-report__metric-value.negative { + color: var(--reflection-danger); +} + +/* Section Card */ +.reflection-report__section { + background: rgba(17, 24, 39, 0.6); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 24px; + padding: 2rem; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + margin-bottom: 1.5rem; + animation: sectionFadeIn 0.6s ease-out both; +} + +.reflection-report__section:nth-child(1) { animation-delay: 0.1s; } +.reflection-report__section:nth-child(2) { animation-delay: 0.2s; } +.reflection-report__section:nth-child(3) { animation-delay: 0.3s; } +.reflection-report__section:nth-child(4) { animation-delay: 0.4s; } +.reflection-report__section:nth-child(5) { animation-delay: 0.5s; } + +@keyframes sectionFadeIn { + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.reflection-report__section-header { + display: flex; + align-items: center; + gap: 0.875rem; + margin-bottom: 1.5rem; +} + +.reflection-report__section-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(129, 140, 248, 0.15) 0%, rgba(244, 114, 182, 0.1) 100%); + border-radius: 12px; + color: var(--reflection-accent); +} + +.reflection-report__section-icon svg { + width: 20px; + height: 20px; +} + +.reflection-report__section-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + letter-spacing: -0.01em; +} + +/* Summary Section */ +.reflection-report__summary-text { + font-size: 1rem; + line-height: 1.8; + color: var(--color-text-secondary); + padding-left: 1rem; + border-left: 3px solid rgba(129, 140, 248, 0.3); +} + +/* Findings Grid */ +.reflection-report__findings-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +@media (max-width: 768px) { + .reflection-report__findings-grid { + grid-template-columns: 1fr; + } +} + +.reflection-report__finding-card { + padding: 1.25rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 16px; + transition: all 0.2s ease; +} + +.reflection-report__finding-card:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); +} + +.reflection-report__finding-card--strengths { + border-left: 3px solid var(--reflection-success); +} + +.reflection-report__finding-card--mistakes { + border-left: 3px solid var(--reflection-danger); +} + +.reflection-report__finding-card--patterns { + border-left: 3px solid var(--reflection-info); +} + +.reflection-report__finding-card--risks { + border-left: 3px solid var(--reflection-warning); +} + +.reflection-report__finding-label { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reflection-report__finding-label svg { + width: 14px; + height: 14px; +} + +.reflection-report__finding-card--strengths .reflection-report__finding-label svg { + color: var(--reflection-success); +} + +.reflection-report__finding-card--mistakes .reflection-report__finding-label svg { + color: var(--reflection-danger); +} + +.reflection-report__finding-card--patterns .reflection-report__finding-label svg { + color: var(--reflection-info); +} + +.reflection-report__finding-card--risks .reflection-report__finding-label svg { + color: var(--reflection-warning); +} + +.reflection-report__finding-content { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-text-primary); +} + +/* Recommendation Block */ +.reflection-report__recommendation { + margin-top: 1.5rem; + padding: 1.5rem; + background: linear-gradient(135deg, rgba(129, 140, 248, 0.06) 0%, rgba(34, 211, 238, 0.04) 100%); + border: 1px solid rgba(129, 140, 248, 0.12); + border-radius: 16px; +} + +.reflection-report__recommendation-title { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--reflection-accent); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reflection-report__recommendation-title svg { + width: 16px; + height: 16px; +} + +.reflection-report__recommendation-text { + font-size: 0.9375rem; + line-height: 1.7; + color: var(--color-text-primary); +} + +/* Current Strategy Info */ +.reflection-report__strategy-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.reflection-report__strategy-block { + padding: 1.25rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 12px; +} + +.reflection-report__strategy-block-title { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 0.75rem; +} + +.reflection-report__strategy-block-content { + font-size: 0.875rem; + line-height: 1.6; + color: var(--color-text-primary); +} + +.reflection-report__strategy-block-content strong { + color: var(--reflection-accent); + font-weight: 600; +} + +/* Adjustments Table */ +.reflection-report__table-wrapper { + overflow-x: auto; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.reflection-report__table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.reflection-report__table th { + padding: 1rem 1.25rem; + text-align: left; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + white-space: nowrap; +} + +.reflection-report__table td { + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + vertical-align: middle; +} + +.reflection-report__table tr:last-child td { + border-bottom: none; +} + +.reflection-report__table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.reflection-report__table-param { + font-weight: 600; + color: var(--reflection-accent); + font-variant-numeric: tabular-nums; +} + +.reflection-report__table-value { + font-variant-numeric: tabular-nums; + color: var(--color-text-primary); +} + +.reflection-report__table-direction { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; +} + +.reflection-report__table-direction--up { + background: rgba(52, 211, 153, 0.15); + color: var(--reflection-success); +} + +.reflection-report__table-direction--down { + background: rgba(248, 113, 113, 0.15); + color: var(--reflection-danger); +} + +.reflection-report__table-reason { + color: var(--color-text-secondary); + max-width: 200px; +} + +.reflection-report__table-action .btn { + padding: 0.5rem 1rem; + font-size: 0.8125rem; +} + +/* Status Badge */ +.reflection-report__status-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; +} + +.reflection-report__status-badge--applied { + background: rgba(52, 211, 153, 0.15); + color: var(--reflection-success); +} + +.reflection-report__status-badge--applied svg { + width: 12px; + height: 12px; +} + +/* Empty State */ +.reflection-report__empty { + padding: 2rem; + text-align: center; + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.02); + border-radius: 16px; + border: 1px dashed rgba(255, 255, 255, 0.06); +} + +.reflection-report__empty-icon { + width: 48px; + height: 48px; + margin: 0 auto 1rem; + color: var(--color-text-muted); + opacity: 0.5; +} + +/* Footer Actions */ +.reflection-report__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding-top: 2rem; + margin-top: 2rem; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +/* Alerts */ +.reflection-report__alert { + padding: 1rem 1.25rem; + border-radius: 12px; + margin-bottom: 1.5rem; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.reflection-report__alert--success { + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.2); + color: var(--reflection-success); +} + +.reflection-report__alert--error { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.2); + color: var(--reflection-danger); +} + +.reflection-report__alert svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +/* Buttons */ +.reflection-report__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + border: none; +} + +.reflection-report__btn--primary { + background: linear-gradient(135deg, var(--reflection-accent) 0%, #a78bfa 100%); + color: white; + box-shadow: 0 4px 16px rgba(129, 140, 248, 0.25); +} + +.reflection-report__btn--primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 24px rgba(129, 140, 248, 0.35); +} + +.reflection-report__btn--ghost { + background: transparent; + color: var(--color-text-secondary); +} + +.reflection-report__btn--ghost:hover { + color: var(--reflection-accent); + background: rgba(129, 140, 248, 0.1); +} + +/* Trader Show Page - Reflection Preview Section */ +.reflection-preview { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.reflection-preview__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; +} + +.reflection-preview__intro { + flex: 1; +} + +.reflection-preview__title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.625rem; +} + +.reflection-preview__title-icon { + width: 24px; + height: 24px; + color: var(--reflection-accent); +} + +.reflection-preview__description { + font-size: 0.875rem; + color: var(--color-text-secondary); + line-height: 1.6; + max-width: 480px; +} + +.reflection-preview__generate-btn { + flex-shrink: 0; +} + +/* Latest Report Card */ +.reflection-preview__report-card { + padding: 1.5rem; + background: linear-gradient(135deg, rgba(129, 140, 248, 0.06) 0%, rgba(244, 114, 182, 0.04) 100%); + border: 1px solid rgba(129, 140, 248, 0.12); + border-radius: 20px; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.reflection-preview__report-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.reflection-preview__report-meta { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.reflection-preview__report-eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--reflection-accent); +} + +.reflection-preview__report-period { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reflection-preview__report-period svg { + width: 16px; + height: 16px; + color: var(--color-text-muted); +} + +.reflection-preview__report-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(129, 140, 248, 0.1); + border: 1px solid rgba(129, 140, 248, 0.2); + border-radius: 10px; + color: var(--reflection-accent); + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; +} + +.reflection-preview__report-link:hover { + background: rgba(129, 140, 248, 0.15); + border-color: rgba(129, 140, 248, 0.3); + transform: translateY(-1px); +} + +.reflection-preview__report-link svg { + width: 14px; + height: 14px; +} + +/* Summary */ +.reflection-preview__summary { + padding: 1rem 1.25rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border-left: 3px solid var(--reflection-accent); +} + +.reflection-preview__summary-text { + font-size: 0.875rem; + line-height: 1.7; + color: var(--color-text-secondary); +} + +/* Findings Preview Grid */ +.reflection-preview__findings { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 900px) { + .reflection-preview__findings { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .reflection-preview__findings { + grid-template-columns: 1fr; + } +} + +.reflection-preview__finding-item { + padding: 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 12px; + transition: all 0.2s ease; +} + +.reflection-preview__finding-item:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); +} + +.reflection-preview__finding-item--strengths { + border-top: 2px solid var(--reflection-success); +} + +.reflection-preview__finding-item--mistakes { + border-top: 2px solid var(--reflection-danger); +} + +.reflection-preview__finding-item--risks { + border-top: 2px solid var(--reflection-warning); +} + +.reflection-preview__finding-item--adjustments { + border-top: 2px solid var(--reflection-info); +} + +.reflection-preview__finding-label { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.reflection-preview__finding-label svg { + width: 12px; + height: 12px; +} + +.reflection-preview__finding-item--strengths .reflection-preview__finding-label svg { + color: var(--reflection-success); +} + +.reflection-preview__finding-item--mistakes .reflection-preview__finding-label svg { + color: var(--reflection-danger); +} + +.reflection-preview__finding-item--risks .reflection-preview__finding-label svg { + color: var(--reflection-warning); +} + +.reflection-preview__finding-item--adjustments .reflection-preview__finding-label svg { + color: var(--reflection-info); +} + +.reflection-preview__finding-value { + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-primary); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.reflection-preview__finding-count { + font-size: 1.5rem; + font-weight: 700; + color: var(--reflection-info); +} + +/* Footer Meta */ +.reflection-preview__footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.reflection-preview__generated-time { + font-size: 0.75rem; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.reflection-preview__generated-time svg { + width: 12px; + height: 12px; +} + +/* Empty State */ +.reflection-preview__empty { + padding: 2rem; + text-align: center; + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.08); + border-radius: 16px; +} + +.reflection-preview__empty-icon { + width: 48px; + height: 48px; + margin: 0 auto 1rem; + color: var(--color-text-muted); + opacity: 0.5; +} + +.reflection-preview__empty-text { + font-size: 0.875rem; + color: var(--color-text-secondary); + margin: 0; +} + +/* Error State */ +.reflection-preview__error { + padding: 1rem 1.25rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.2); + border-radius: 12px; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.reflection-preview__error svg { + width: 18px; + height: 18px; + color: var(--reflection-danger); + flex-shrink: 0; + margin-top: 2px; +} + +.reflection-preview__error-text { + font-size: 0.875rem; + color: var(--reflection-danger); + line-height: 1.5; +} + +/* Responsive */ +@media (max-width: 900px) { + .reflection-report__hero { + grid-template-columns: 1fr; + } + + .reflection-report__metrics-card { + min-width: 100%; + } + + .reflection-report__header { + padding: 1rem 1.5rem; + } + + .reflection-report__main { + padding: 1.5rem; + } + + .reflection-report__section { + padding: 1.5rem; + } + + .reflection-report__footer { + flex-direction: column; + align-items: stretch; + } + + /* Reflection Preview Responsive */ + .reflection-preview__header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .reflection-preview__generate-btn { + align-self: flex-start; + } + + .reflection-preview__report-header { + flex-direction: column; + gap: 1rem; + } + + .reflection-preview__report-link { + align-self: flex-start; + } +} + +@media (max-width: 640px) { + .reflection-report__header-left { + gap: 0.75rem; + } + + .reflection-report__title { + font-size: 1.125rem; + } + + .reflection-report__trader-name { + font-size: 1.5rem; + } + + .reflection-report__strategy-info { + grid-template-columns: 1fr; + } + + .reflection-report__table-wrapper { + margin: 0 -0.5rem; + border-radius: 0; + border-left: none; + border-right: none; + } + + .reflection-report__table th, + .reflection-report__table td { + padding: 0.75rem 1rem; + } + + /* Reflection Preview Responsive */ + .reflection-preview__report-card { + padding: 1.25rem; + } + + .reflection-preview__summary { + padding: 0.875rem 1rem; + } + + .reflection-preview__footer { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} diff --git a/app/assets/stylesheets/traders.css b/app/assets/stylesheets/traders.css index d0b23da..8159d33 100644 --- a/app/assets/stylesheets/traders.css +++ b/app/assets/stylesheets/traders.css @@ -976,6 +976,23 @@ color: #6b7280; } +.table-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + padding: 0.375rem 0.625rem; + border-radius: 999px; +} + +.table-status-badge--applied { + background: rgba(34, 197, 94, 0.15); + color: #15803d; +} + /* Strategy Card */ .strategy-card { padding: 1.5rem; diff --git a/app/controllers/trader_reflections_controller.rb b/app/controllers/trader_reflections_controller.rb new file mode 100644 index 0000000..dccaf8c --- /dev/null +++ b/app/controllers/trader_reflections_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class TraderReflectionsController < ApplicationController + before_action :require_user + before_action :set_trader + before_action :set_trader_reflection, only: %i[show apply_adjustment] + + def create + reflection = TraderReflectionService.new(@trader).call + redirect_to trader_trader_reflection_path(@trader, reflection), notice: "反思报告生成成功" + rescue StandardError => e + redirect_to trader_path(@trader), alert: "反思报告生成失败:#{e.message}" + end + + def show; end + + def apply_adjustment + ApplyTraderReflectionAdjustmentService.new( + @trader_reflection, + parameter: params[:parameter] + ).call + + redirect_to trader_trader_reflection_path(@trader, @trader_reflection), notice: "策略参数已应用到当前策略" + rescue StandardError => e + redirect_to trader_trader_reflection_path(@trader, @trader_reflection), alert: "应用建议失败:#{e.message}" + end + + private + + def set_trader + @trader = Trader.find(params[:trader_id]) + end + + def set_trader_reflection + @trader_reflection = @trader.trader_reflections.find(params[:id]) + end +end diff --git a/app/controllers/traders_controller.rb b/app/controllers/traders_controller.rb index 21093df..2250cdd 100644 --- a/app/controllers/traders_controller.rb +++ b/app/controllers/traders_controller.rb @@ -13,7 +13,9 @@ def show @latest_allocation_task = @trader.allocation_tasks.recent.first @latest_portfolio_snapshot = @trader.portfolio_snapshots.recent.first @trader_positions = @trader.trader_positions.active.includes(:asset).ordered_by_value + @trader_position_rows = build_trader_position_rows(@trader_positions) @recent_trader_trades = @trader.trader_trades.recent.includes(:asset).limit(10) + @latest_trader_reflection = @trader.trader_reflections.recent.first end def new @@ -73,4 +75,27 @@ def regenerate_strategies_if_needed(trader) trader.trading_strategies.destroy_all generate_strategies_for(trader) end + + def build_trader_position_rows(positions) + positions.map do |position| + quantity = position.quantity.to_d + latest_price = position.asset.latest_snapshot&.price&.to_d || position.current_price.to_d + market_value = (quantity * latest_price).round(2) + cost_basis = (quantity * position.average_cost.to_d).round(2) + unrealized_pnl = (market_value - cost_basis).round(2) + unrealized_pnl_percent = if cost_basis.positive? + ((unrealized_pnl / cost_basis) * 100).round(2) + else + 0 + end + + { + position: position, + latest_price: latest_price, + market_value: market_value, + unrealized_pnl: unrealized_pnl, + unrealized_pnl_percent: unrealized_pnl_percent + } + end + end end diff --git a/app/models/strategy_adjustment_log.rb b/app/models/strategy_adjustment_log.rb new file mode 100644 index 0000000..8f47c87 --- /dev/null +++ b/app/models/strategy_adjustment_log.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class StrategyAdjustmentLog < ApplicationRecord + belongs_to :trader_reflection + belongs_to :trading_strategy + + validates :parameter, :direction, :applied_at, presence: true +end diff --git a/app/models/trader.rb b/app/models/trader.rb index c59eaac..bd16034 100644 --- a/app/models/trader.rb +++ b/app/models/trader.rb @@ -5,6 +5,7 @@ class Trader < ApplicationRecord has_many :allocation_decisions, dependent: :destroy has_many :allocation_tasks, dependent: :destroy has_many :portfolio_snapshots, dependent: :destroy + has_many :trader_reflections, dependent: :destroy has_many :trader_positions, dependent: :destroy has_many :trader_trades, dependent: :destroy has_many :trading_strategies, dependent: :destroy diff --git a/app/models/trader_reflection.rb b/app/models/trader_reflection.rb new file mode 100644 index 0000000..baff311 --- /dev/null +++ b/app/models/trader_reflection.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class TraderReflection < ApplicationRecord + belongs_to :trader + belongs_to :trading_strategy, optional: true + has_many :strategy_adjustment_logs, dependent: :destroy + + enum :status, { pending: 0, generated: 1, failed: 2 } + + validates :reflection_period_start, :reflection_period_end, :source, :prompt_version, presence: true + + scope :recent, -> { order(reflection_period_end: :desc, created_at: :desc) } +end diff --git a/app/models/trading_strategy.rb b/app/models/trading_strategy.rb index 4e74106..5f7a593 100644 --- a/app/models/trading_strategy.rb +++ b/app/models/trading_strategy.rb @@ -3,6 +3,8 @@ class TradingStrategy < ApplicationRecord belongs_to :trader has_many :allocation_decisions, dependent: :nullify + has_many :trader_reflections, dependent: :nullify + has_many :strategy_adjustment_logs, dependent: :nullify # Enums enum :risk_level, { conservative: 0, balanced: 1, aggressive: 2 } diff --git a/app/services/apply_trader_reflection_adjustment_service.rb b/app/services/apply_trader_reflection_adjustment_service.rb new file mode 100644 index 0000000..173ba08 --- /dev/null +++ b/app/services/apply_trader_reflection_adjustment_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class ApplyTraderReflectionAdjustmentService + PARAMETER_RULES = { + "max_positions" => { step: 1, min: 2, max: 5, integer: true }, + "buy_signal_threshold" => { step: 0.05, min: 0.3, max: 0.7, integer: false }, + "max_position_size" => { step: 0.05, min: 0.3, max: 0.7, integer: false }, + "min_cash_reserve" => { step: 0.05, min: 0.05, max: 0.4, integer: false } + }.freeze + + def self.preview_value(parameter, before_value, direction) + rule = PARAMETER_RULES[parameter.to_s] + raise ArgumentError, "不支持的策略参数" unless rule + + adjusted_value(before_value, direction, rule) + end + + def initialize(trader_reflection, parameter:) + @trader_reflection = trader_reflection + @parameter = parameter.to_s + end + + def call + adjustment = suggested_adjustment + raise ArgumentError, "未找到对应的参数调整建议" unless adjustment + raise ArgumentError, "该参数建议已经应用过了" if applied_log.present? + + strategy = @trader_reflection.trading_strategy || @trader_reflection.trader.default_strategy + raise ArgumentError, "当前 trader 没有可调整的策略" unless strategy + + rule = PARAMETER_RULES[@parameter] + raise ArgumentError, "不支持的策略参数" unless rule + + before_value = strategy.public_send(@parameter) + after_value = self.class.preview_value(@parameter, before_value, adjustment["direction"]) + + TradingStrategy.transaction do + strategy.update!( + @parameter => after_value, + generated_by: :manual + ) + + StrategyAdjustmentLog.create!( + trader_reflection: @trader_reflection, + trading_strategy: strategy, + parameter: @parameter, + direction: adjustment["direction"], + reason: adjustment["reason"], + before_value: before_value, + after_value: after_value, + applied_at: Time.current + ) + end + + strategy + end + + private + + def suggested_adjustment + Array(@trader_reflection.suggested_adjustments).find do |adjustment| + adjustment["parameter"].to_s == @parameter + end + end + + def applied_log + @applied_log ||= @trader_reflection.strategy_adjustment_logs.find_by(parameter: @parameter) + end + + def self.adjusted_value(before_value, direction, rule) + raw_value = before_value.to_d + + updated_value = case direction.to_s + when "increase" + raw_value + BigDecimal(rule[:step].to_s) + when "decrease" + raw_value - BigDecimal(rule[:step].to_s) + else + raw_value + end + + clamped_value = [[updated_value, BigDecimal(rule[:min].to_s)].max, BigDecimal(rule[:max].to_s)].min + rule[:integer] ? clamped_value.to_i : clamped_value.round(2).to_f + end +end diff --git a/app/services/trader_reflection_service.rb b/app/services/trader_reflection_service.rb new file mode 100644 index 0000000..30e0d08 --- /dev/null +++ b/app/services/trader_reflection_service.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +class TraderReflectionService + PROMPT_VERSION = "v1" + + def initialize(trader, period_start: 30.days.ago.to_date, period_end: Date.current) + @trader = trader + @period_start = period_start + @period_end = period_end + end + + def call + reflection = @trader.trader_reflections.find_or_initialize_by( + reflection_period_start: @period_start, + reflection_period_end: @period_end + ) + + reflection.assign_attributes( + trading_strategy: @trader.default_strategy, + status: :pending, + source: llm_available? ? "llm" : "fallback", + prompt_version: PROMPT_VERSION, + metrics: metrics_payload, + findings: {}, + suggested_adjustments: [], + error_message: nil + ) + reflection.save! + + parsed = llm_available? ? generate_with_llm : fallback_report + + reflection.update!( + status: :generated, + llm_summary: parsed[:summary], + findings: parsed[:findings], + suggested_adjustments: parsed[:suggested_adjustments], + generated_at: Time.current + ) + + reflection + rescue StandardError => e + reflection&.update(status: :failed, error_message: e.message) + raise + end + + private + + def llm_available? + ENV["OPENAI_API_KEY"].present? && ENV["OPENAI_API_BASE"].present? + end + + def generate_with_llm + response = reflection_agent.ask(prompt) + + parsed = parse_json(response.content) + + { + summary: parsed["summary"].presence || fallback_report[:summary], + findings: { + "strengths" => Array(parsed["strengths"]).compact, + "mistakes" => Array(parsed["mistakes"]).compact, + "pattern_findings" => Array(parsed["pattern_findings"]).compact, + "risk_issues" => Array(parsed["risk_issues"]).compact, + "recommendation" => parsed["recommendation"].to_s + }, + suggested_adjustments: Array(parsed["suggested_adjustments"]).compact + } + rescue StandardError + fallback_report + end + + def parse_json(content) + clean = content.to_s.gsub(/```json\s*|\s*```/i, "").strip + json_match = clean.match(/\{.*\}/m) + JSON.parse(json_match ? json_match[0] : clean) + end + + def fallback_report + metrics = metrics_payload + total_profit_loss = metrics["total_profit_loss"].to_d + trade_count = metrics["trade_count"].to_i + risk_issues = [] + risk_issues << "近期交易次数偏少,样本有限,结论置信度较低。" if trade_count < 3 + risk_issues << "当前持仓浮亏较大,需复核入场时机与仓位控制。" if metrics["unrealized_pnl"].to_d < -1000 + + { + summary: if total_profit_loss.positive? + "最近一段时间整体仍有盈利,但需要继续复核持仓质量与风险控制。" + elsif total_profit_loss.zero? + "最近一段时间整体接近打平,说明策略尚未形成明显优势。" + else + "最近一段时间整体表现偏弱,应优先复核买入门槛、仓位集中度和现金保留比例。" + end, + findings: { + "strengths" => [ + "当前策略和交易执行链路完整,能够持续产出 recommendation 并落地执行。" + ], + "mistakes" => [ + "近期表现说明策略参数与市场环境之间可能存在错配。" + ], + "pattern_findings" => [ + "反思结果基于最近 30 天执行记录、交易流水、组合快照和当前持仓。" + ], + "risk_issues" => risk_issues, + "recommendation" => "建议先阅读反思报告,再决定是否人工微调现有策略参数。" + }, + suggested_adjustments: suggested_adjustments_from_metrics(metrics) + } + end + + def suggested_adjustments_from_metrics(metrics) + adjustments = [] + + if metrics["unrealized_pnl"].to_d < -1000 + adjustments << { + "parameter" => "buy_signal_threshold", + "direction" => "increase", + "reason" => "近期持仓浮亏偏大,建议提高买入门槛,减少边际信号质量不足的入场。" + } + end + + if metrics["cash_ratio"].to_d < 0.1 + adjustments << { + "parameter" => "min_cash_reserve", + "direction" => "increase", + "reason" => "当前现金比例偏低,建议提高现金保留,增强回撤期缓冲能力。" + } + end + + adjustments + end + + def prompt + <<~PROMPT + 请基于以下 trader 反思上下文,输出 JSON: + + #{JSON.pretty_generate(reflection_payload)} + + 返回格式: + { + "summary": "string", + "strengths": ["string"], + "mistakes": ["string"], + "pattern_findings": ["string"], + "risk_issues": ["string"], + "suggested_adjustments": [ + { + "parameter": "max_positions|buy_signal_threshold|max_position_size|min_cash_reserve", + "direction": "increase|decrease|keep", + "reason": "string" + } + ], + "recommendation": "string" + } + PROMPT + end + + def reflection_agent + @reflection_agent ||= TraderReflectionAgent.new + end + + def reflection_payload + { + trader: { + id: @trader.id, + name: @trader.name, + risk_level: @trader.risk_level, + initial_capital: @trader.initial_capital.to_d.round(2).to_f + }, + period: { + start: @period_start.iso8601, + end: @period_end.iso8601 + }, + strategy: strategy_payload, + metrics: metrics_payload, + recent_trades: trades_payload, + recent_tasks: tasks_payload, + current_positions: positions_payload + } + end + + def strategy_payload + strategy = @trader.default_strategy + return {} unless strategy + + { + id: strategy.id, + name: strategy.name, + market_condition: strategy.market_condition, + max_positions: strategy.max_positions, + buy_signal_threshold: strategy.buy_signal_threshold.to_f, + max_position_size: strategy.max_position_size.to_f, + min_cash_reserve: strategy.min_cash_reserve.to_f + } + end + + def metrics_payload + @metrics_payload ||= begin + trades = scoped_trades.to_a + tasks = scoped_tasks.to_a + latest_snapshot = scoped_snapshots.max_by { |snapshot| [snapshot.snapshot_date, snapshot.captured_at] } || + @trader.portfolio_snapshots.recent.first + current_unrealized = current_unrealized_metrics + latest_portfolio_value = latest_snapshot&.portfolio_value.to_d.nonzero? || @trader.current_capital_value.to_d + total_profit_loss = if latest_snapshot.present? + latest_snapshot.profit_loss.to_d + else + (latest_portfolio_value - @trader.initial_capital.to_d).round(2) + end + equity_value = latest_portfolio_value.to_d + cash_value = latest_snapshot&.cash_value.to_d + cash_ratio = equity_value.positive? ? (cash_value / equity_value).round(4) : 0.to_d + + { + "trade_count" => trades.size, + "buy_count" => trades.count { |trade| trade.action == "buy" }, + "sell_count" => trades.count { |trade| trade.action == "sell" }, + "completed_task_count" => tasks.count(&:completed?), + "failed_task_count" => tasks.count(&:failed?), + "total_buy_amount" => trades.select { |trade| trade.action == "buy" }.sum { |trade| trade.amount.to_d }.round(2).to_f, + "total_sell_amount" => trades.select { |trade| trade.action == "sell" }.sum { |trade| trade.amount.to_d }.round(2).to_f, + "latest_portfolio_value" => latest_portfolio_value.round(2).to_f, + "total_profit_loss" => total_profit_loss.round(2).to_f, + "total_profit_loss_percent" => if @trader.initial_capital.to_d.positive? + ((total_profit_loss / @trader.initial_capital.to_d) * 100).round(2).to_f + else + 0.0 + end, + "cash_value" => cash_value.round(2).to_f, + "cash_ratio" => cash_ratio.to_f, + "unrealized_pnl" => current_unrealized[:unrealized_pnl].to_f, + "unrealized_pnl_percent" => current_unrealized[:unrealized_pnl_percent].to_f, + "position_count" => @trader.trader_positions.active.count + } + end + end + + def current_unrealized_metrics + unrealized_pnl = 0.to_d + cost_basis = 0.to_d + + @trader.trader_positions.active.includes(:asset).each do |position| + quantity = position.quantity.to_d + latest_price = position.asset.latest_snapshot&.price&.to_d || position.current_price.to_d + market_value = (quantity * latest_price).round(2) + position_cost_basis = (quantity * position.average_cost.to_d).round(2) + unrealized_pnl += market_value - position_cost_basis + cost_basis += position_cost_basis + end + + unrealized_pnl = unrealized_pnl.round(2) + + { + unrealized_pnl: unrealized_pnl, + unrealized_pnl_percent: cost_basis.positive? ? ((unrealized_pnl / cost_basis) * 100).round(2) : 0 + } + end + + def trades_payload + scoped_trades.limit(20).map do |trade| + { + executed_at: trade.executed_at&.iso8601, + action: trade.action, + symbol: trade.asset.symbol, + amount: trade.amount.to_d.round(2).to_f, + price: trade.price.to_d.round(2).to_f, + reason: trade.reason + } + end + end + + def tasks_payload + scoped_tasks.limit(10).map do |task| + { + run_on: task.run_on&.iso8601, + status: task.status, + ending_cash: task.ending_cash.to_d.round(2).to_f, + portfolio_value: task.portfolio_value.to_d.round(2).to_f, + summary: task.summary + } + end + end + + def positions_payload + @trader.trader_positions.active.includes(:asset).ordered_by_value.limit(10).map do |position| + latest_price = position.asset.latest_snapshot&.price&.to_d || position.current_price.to_d + + { + symbol: position.asset.symbol, + asset_name: position.asset.name, + quantity: position.quantity.to_d.round(6).to_f, + average_cost: position.average_cost.to_d.round(2).to_f, + current_price: latest_price.round(2).to_f + } + end + end + + def scoped_trades + @scoped_trades ||= @trader.trader_trades + .includes(:asset) + .where(executed_at: period_range) + .order(executed_at: :desc, created_at: :desc) + end + + def scoped_tasks + @scoped_tasks ||= @trader.allocation_tasks + .where(run_on: @period_start..@period_end) + .order(run_on: :desc, created_at: :desc) + end + + def scoped_snapshots + @scoped_snapshots ||= @trader.portfolio_snapshots + .where(snapshot_date: @period_start..@period_end) + .order(snapshot_date: :desc, captured_at: :desc) + end + + def period_range + @period_start.beginning_of_day..@period_end.end_of_day + end +end diff --git a/app/views/trader_reflections/show.html.erb b/app/views/trader_reflections/show.html.erb new file mode 100644 index 0000000..10f83f8 --- /dev/null +++ b/app/views/trader_reflections/show.html.erb @@ -0,0 +1,404 @@ +<% content_for :title, "#{@trader.name} - 交易反思报告" %> + +
+
+
+
+
+
+ +
+
+ <%= link_to trader_path(@trader), class: "reflection-report__back-btn" do %> + + + + + <% end %> +
+ AI Analysis Report +

交易反思报告

+
+
+
+ <%= button_to "重新生成", trader_trader_reflections_path(@trader), method: :post, class: "reflection-report__btn reflection-report__btn--primary" %> +
+
+ +
+ <% if notice.present? %> +
+ + + + + <%= notice %> +
+ <% end %> + + <% if alert.present? %> +
+ + + + + + <%= alert %> +
+ <% end %> + + +
+
+

<%= @trader.name %>

+
+ + + + + + + + <%= @trader_reflection.reflection_period_start %> 至 <%= @trader_reflection.reflection_period_end %> + +
+
+
+ + + + + 状态 + <%= @trader_reflection.status %> +
+
+ + + + + 来源 + <%= @trader_reflection.source %> +
+ <% if @trader_reflection.generated_at.present? %> +
+ + + + + 生成时间 + <%= l(@trader_reflection.generated_at, format: :short) %> +
+ <% end %> +
+
+ + +
+

关键指标快照

+
+ <% metrics = @trader_reflection.metrics || {} %> +
+ 交易次数 + <%= metrics["trade_count"] || 0 %> +
+
+ 完成任务数 + <%= metrics["completed_task_count"] || 0 %> +
+
+ 累计盈亏 + <% total_pnl = metrics["total_profit_loss"] || 0 %> + + <%= number_to_currency(total_pnl, unit: "$", precision: 2) %> + +
+
+ 持仓浮盈亏 + <% unrealized_pnl = metrics["unrealized_pnl"] || 0 %> + + <%= number_to_currency(unrealized_pnl, unit: "$", precision: 2) %> + +
+
+
+
+ + +
+
+
+ + + + + + + +
+

反思总结

+
+

+ <%= @trader_reflection.llm_summary.presence || "暂无总结" %> +

+
+ + +
+
+
+ + + + +
+

核心发现

+
+ + <% findings = @trader_reflection.findings || {} %> + +
+
+
+ + + + 优点 +
+
+ <%= Array(findings["strengths"]).join(";").presence || "-" %> +
+
+ +
+
+ + + + + + 问题 +
+
+ <%= Array(findings["mistakes"]).join(";").presence || "-" %> +
+
+ +
+
+ + + + 模式观察 +
+
+ <%= Array(findings["pattern_findings"]).join(";").presence || "-" %> +
+
+ +
+
+ + + + + + 风险提示 +
+
+ <%= Array(findings["risk_issues"]).join(";").presence || "-" %> +
+
+
+ + <% if findings["recommendation"].present? %> +
+
+ + + + + + 总体建议 +
+

<%= findings["recommendation"] %>

+
+ <% end %> +
+ + +
+
+
+ + + + + + + + + + + +
+

建议微调参数

+
+ + <% current_strategy = @trader_reflection.trading_strategy || @trader.default_strategy %> + + <% if current_strategy.present? %> +
+
+
当前生效策略
+
+ 策略名称:<%= current_strategy.name %>
+ 市场环境:<%= current_strategy.display_market_condition %>
+ 来源:<%= current_strategy.display_generated_by %> +
+
+
+
当前策略参数
+
+ 最大持仓数 <%= current_strategy.max_positions %>, + 买入阈值 <%= current_strategy.buy_signal_threshold %>, + 最大仓位 <%= current_strategy.max_position_size %>, + 最小现金保留 <%= current_strategy.min_cash_reserve %> +
+
+
+

+ 💡 本页建议只会作用于这套策略,不会自动修改其他市场环境策略。 +

+ <% end %> + + <% if Array(@trader_reflection.suggested_adjustments).any? %> +
+ + + + + + + + + + + + + <% applied_logs_by_parameter = @trader_reflection.strategy_adjustment_logs.index_by(&:parameter) %> + <% Array(@trader_reflection.suggested_adjustments).each do |adjustment| %> + <% applied_log = applied_logs_by_parameter[adjustment["parameter"].to_s] %> + <% current_value = if applied_log.present? + applied_log.before_value + elsif current_strategy.present? + current_strategy.public_send(adjustment["parameter"]) + end %> + <% suggested_value = if applied_log.present? + applied_log.after_value + elsif current_value.present? + ApplyTraderReflectionAdjustmentService.preview_value(adjustment["parameter"], current_value, adjustment["direction"]) + end %> + + + + + + + + + <% end %> + +
参数当前值建议后值方向原因操作
<%= adjustment["parameter"] %><%= current_value || "-" %><%= suggested_value || "-" %> + <% direction_class = adjustment["direction"].to_s.include?("increase") ? "up" : "down" %> + + <% if direction_class == "up" %> + + + + <% else %> + + + + <% end %> + <%= adjustment["direction"] %> + + <%= adjustment["reason"] %> + <% if applied_log.present? %> + + + + + 已应用 + + <% else %> + <%= button_to "应用到这套策略", apply_adjustment_trader_trader_reflection_path(@trader, @trader_reflection, parameter: adjustment["parameter"]), method: :post, class: "reflection-report__btn reflection-report__btn--primary" %> + <% end %> +
+
+ <% else %> +
+ + + + +

当前没有建议微调的参数,说明这次反思更偏向复盘和观察。

+
+ <% end %> +
+ + + <% if @trader_reflection.strategy_adjustment_logs.any? %> +
+
+
+ + + + +
+

已应用调整记录

+
+
+ + + + + + + + + + + + <% @trader_reflection.strategy_adjustment_logs.order(applied_at: :desc).each do |log| %> + + + + + + + + <% end %> + +
时间参数方向变更原因
<%= l(log.applied_at, format: :short) %><%= log.parameter %> + <% direction_class = log.direction.to_s.include?("increase") ? "up" : "down" %> + + <%= log.direction %> + + <%= log.before_value %> → <%= log.after_value %><%= log.reason %>
+
+
+ <% end %> + + +
+ <%= link_to trader_path(@trader), class: "reflection-report__btn reflection-report__btn--ghost" do %> + + + + + 返回操盘手详情 + <% end %> +
+
+
diff --git a/app/views/traders/show.html.erb b/app/views/traders/show.html.erb index 0027838..67af74d 100644 --- a/app/views/traders/show.html.erb +++ b/app/views/traders/show.html.erb @@ -35,6 +35,12 @@ <% end %> + <% if alert.present? %> +
+ <%= alert %> +
+ <% end %> +
@@ -193,16 +199,17 @@ - <% @trader_positions.each do |position| %> + <% @trader_position_rows.each do |row| %> + <% position = row[:position] %> <%= position.asset.symbol %> <%= position.asset.name %> <%= number_with_precision(position.quantity, precision: 6, strip_insignificant_zeros: true) %> <%= number_to_currency(position.average_cost, unit: "$", precision: 2) %> - <%= number_to_currency(position.current_price, unit: "$", precision: 2) %> - <%= number_to_currency(position.market_value, unit: "$", precision: 2) %> - - <%= position.unrealized_pnl >= 0 ? '+' : '' %><%= number_to_currency(position.unrealized_pnl, unit: "$", precision: 2) %> - (<%= position.unrealized_pnl_percent >= 0 ? '+' : '' %><%= position.unrealized_pnl_percent.to_f.round(2) %>%) + <%= number_to_currency(row[:latest_price], unit: "$", precision: 2) %> + <%= number_to_currency(row[:market_value], unit: "$", precision: 2) %> + + <%= row[:unrealized_pnl] >= 0 ? '+' : '' %><%= number_to_currency(row[:unrealized_pnl], unit: "$", precision: 2) %> + (<%= row[:unrealized_pnl_percent] >= 0 ? '+' : '' %><%= row[:unrealized_pnl_percent].to_f.round(2) %>%) <% end %> @@ -247,6 +254,155 @@
<% end %>
+ +
+

+ + + + + + 交易反思报告 +

+ +
+
+
+

+ 基于最近 30 天的策略、交易、执行记录、组合快照和当前持仓生成一份反思报告,帮助优化交易策略。 +

+
+
+ <%= button_to "生成反思报告", trader_trader_reflections_path(@trader), method: :post, class: "reflection-report__btn reflection-report__btn--primary" %> +
+
+ + <% if @latest_trader_reflection&.generated? %> + <% findings = @latest_trader_reflection.findings || {} %> + <% suggested_adjustments = Array(@latest_trader_reflection.suggested_adjustments) %> + +
+
+
+ 最近一份报告 +
+ + + + + + + <%= @latest_trader_reflection.reflection_period_start %> 至 <%= @latest_trader_reflection.reflection_period_end %> +
+
+ <%= link_to trader_trader_reflection_path(@trader, @latest_trader_reflection), class: "reflection-preview__report-link" do %> + 查看完整报告 + + + + + <% end %> +
+ + <% if @latest_trader_reflection.llm_summary.present? %> +
+

<%= @latest_trader_reflection.llm_summary %>

+
+ <% end %> + +
+
+
+ + + + 优点 +
+
+ <%= Array(findings["strengths"]).first.presence || "-" %> +
+
+ +
+
+ + + + + + 问题 +
+
+ <%= Array(findings["mistakes"]).first.presence || "-" %> +
+
+ +
+
+ + + + + + 风险提示 +
+
+ <%= Array(findings["risk_issues"]).first.presence || "-" %> +
+
+ +
+
+ + + + + + + + + 建议调整 +
+
+ <%= suggested_adjustments.size %> +
+
+
+ + +
+ <% elsif @latest_trader_reflection&.failed? %> +
+ + + + + + + 最近一次反思报告生成失败:<%= @latest_trader_reflection.error_message %> + +
+ <% else %> +
+ + + + + + +

还没有反思报告。先生成一份最近 30 天的报告,帮助优化策略参数。

+
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 513f262..ce89f84 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,9 @@ resource :allocation_preview, only: [:show] do get :recommendation, on: :collection end + resources :trader_reflections, only: %i[create show] do + post :apply_adjustment, on: :member + end end resources :allocation_decisions, only: [:index, :show] do diff --git a/config/sidekiq.yml b/config/sidekiq.yml index dce0a5f..0d0aed2 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -29,7 +29,8 @@ # Schedule definitions (loaded by sidekiq-scheduler) # 所有任务每天跑两次:UTC 02:00 和 14:00(北京时间 10:00 和 22:00) -# 执行顺序:数据拉取 -> 因子计算(30分钟后) -> 信号生成(再30分钟后) -> recommendation 生成 -> recommendation 执行 -> 盯市快照 +# 执行顺序:数据拉取 -> 因子计算 -> 信号生成 -> recommendation 生成 -> recommendation 执行 -> 盯市快照 +# 按近期实际耗时压缩缓冲,并修正下午批次误落到 15 点后的问题 :schedule: # === 第一批:数据拉取 (UTC 02:00 / 14:00) === @@ -65,40 +66,40 @@ # 交易因子计算 - 依赖数据拉取完成 calculate_factors: - cron: "10 2,14 * * *" + cron: "5 2,14 * * *" class: "CalculateFactorsJob" queue: default - description: "每天两次计算交易因子(数据拉取后30分钟)" + description: "每天两次计算交易因子(数据拉取后约 5 分钟)" - # === 第三批:信号生成 (因子计算后30分钟,UTC 03:00 / 15:00) === + # === 第三批:信号生成 (因子计算后,UTC 02:20 / 14:20) === # 交易信号生成 - 依赖因子计算完成 generate_signals: - cron: "30 2,15 * * *" + cron: "20 2,14 * * *" class: "GenerateSignalsJob" queue: default - description: "每天两次生成交易信号(因子计算后30分钟)" + description: "每天两次生成交易信号(因子计算后约 15 分钟)" - # === 第四批:配置建议生成 (信号生成后10分钟,UTC 03:10 / 15:10) === + # === 第四批:配置建议生成 (信号生成后,UTC 02:30 / 14:30) === generate_daily_allocation_decisions: - cron: "45 2,15 * * *" + cron: "30 2,14 * * *" class: "GenerateDailyAllocationDecisionsJob" queue: default - description: "每天两次为活跃 trader 自动生成 LLM 配置建议" + description: "每天两次为活跃 trader 自动生成 LLM 配置建议(信号生成后约 10 分钟)" - # === 第五批:配置建议执行 (建议生成后10分钟,UTC 03:20 / 15:20) === + # === 第五批:配置建议执行 (建议生成后,UTC 02:40 / 14:40) === execute_daily_allocation_decisions: - cron: "50 2,15 * * *" + cron: "40 2,14 * * *" class: "ExecuteDailyAllocationDecisionsJob" queue: default - description: "每天两次自动执行当天最新有效的 LLM 配置建议" + description: "每天两次自动执行当天最新有效的 LLM 配置建议(建议生成后约 10 分钟)" - # === 第六批:组合盯市快照 (执行后10分钟,UTC 03:30 / 15:30) === + # === 第六批:组合盯市快照 (执行后,UTC 02:45 / 14:45) === mark_to_market_portfolio_snapshots: - cron: "55 2,15 * * *" + cron: "45 2,14 * * *" class: "MarkToMarketPortfolioSnapshotsJob" queue: default - description: "每天两次按最新价格为活跃 trader 记录组合净值快照" + description: "每天两次按最新价格为活跃 trader 记录组合净值快照(执行后约 5 分钟)" diff --git a/db/migrate/20260324112512_create_trader_reflections.rb b/db/migrate/20260324112512_create_trader_reflections.rb new file mode 100644 index 0000000..d016710 --- /dev/null +++ b/db/migrate/20260324112512_create_trader_reflections.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateTraderReflections < ActiveRecord::Migration[8.1] + def change + create_table :trader_reflections do |t| + t.references :trader, null: false, foreign_key: true + t.references :trading_strategy, null: true, foreign_key: true + t.date :reflection_period_start, null: false + t.date :reflection_period_end, null: false + t.integer :status, null: false, default: 0 + t.string :source, null: false, default: "llm" + t.string :prompt_version, null: false, default: "v1" + t.jsonb :metrics, null: false, default: {} + t.text :llm_summary + t.jsonb :findings, null: false, default: {} + t.jsonb :suggested_adjustments, null: false, default: [] + t.datetime :generated_at + t.text :error_message + + t.timestamps + end + + add_index :trader_reflections, [:trader_id, :reflection_period_start, :reflection_period_end], + unique: true, name: "index_trader_reflections_on_trader_and_period" + end +end diff --git a/db/migrate/20260324123000_create_strategy_adjustment_logs.rb b/db/migrate/20260324123000_create_strategy_adjustment_logs.rb new file mode 100644 index 0000000..2f5ee15 --- /dev/null +++ b/db/migrate/20260324123000_create_strategy_adjustment_logs.rb @@ -0,0 +1,16 @@ +class CreateStrategyAdjustmentLogs < ActiveRecord::Migration[8.1] + def change + create_table :strategy_adjustment_logs do |t| + t.references :trader_reflection, null: false, foreign_key: true + t.references :trading_strategy, null: false, foreign_key: true + t.string :parameter, null: false + t.string :direction, null: false + t.text :reason + t.decimal :before_value, precision: 12, scale: 4, null: false + t.decimal :after_value, precision: 12, scale: 4, null: false + t.datetime :applied_at, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d2030e1..651ace0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_17_113000) do +ActiveRecord::Schema[8.1].define(version: 2026_03_24_123000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -110,6 +110,24 @@ t.index ["candle_time"], name: "index_candles_on_candle_time" end + create_table "daily_reports", force: :cascade do |t| + t.text "content", null: false + t.datetime "created_at", null: false + t.string "generated_by", default: "ai" + t.integer "generation_time_ms" + t.string "model_version" + t.boolean "published", default: false + t.date "report_date", null: false + t.string "report_type", null: false + t.jsonb "statistics", default: {} + t.text "summary" + t.datetime "updated_at", null: false + t.index ["published"], name: "index_daily_reports_on_published" + t.index ["report_date"], name: "index_daily_reports_on_report_date" + t.index ["report_type", "report_date"], name: "index_daily_reports_on_report_type_and_report_date", unique: true + t.index ["report_type"], name: "index_daily_reports_on_report_type" + end + create_table "factor_definitions", force: :cascade do |t| t.boolean "active", default: true t.string "calculation_method", null: false @@ -183,6 +201,21 @@ t.index ["trader_id"], name: "index_portfolio_snapshots_on_trader_id" end + create_table "strategy_adjustment_logs", force: :cascade do |t| + t.decimal "after_value", precision: 12, scale: 4, null: false + t.datetime "applied_at", null: false + t.decimal "before_value", precision: 12, scale: 4, null: false + t.datetime "created_at", null: false + t.string "direction", null: false + t.string "parameter", null: false + t.text "reason" + t.bigint "trader_reflection_id", null: false + t.bigint "trading_strategy_id", null: false + t.datetime "updated_at", null: false + t.index ["trader_reflection_id"], name: "index_strategy_adjustment_logs_on_trader_reflection_id" + t.index ["trading_strategy_id"], name: "index_strategy_adjustment_logs_on_trading_strategy_id" + end + create_table "trader_positions", force: :cascade do |t| t.boolean "active", default: true, null: false t.bigint "asset_id", null: false @@ -203,6 +236,27 @@ t.index ["trader_id"], name: "index_trader_positions_on_trader_id" end + create_table "trader_reflections", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error_message" + t.jsonb "findings", default: {}, null: false + t.datetime "generated_at" + t.text "llm_summary" + t.jsonb "metrics", default: {}, null: false + t.string "prompt_version", default: "v1", null: false + t.date "reflection_period_end", null: false + t.date "reflection_period_start", null: false + t.string "source", default: "llm", null: false + t.integer "status", default: 0, null: false + t.jsonb "suggested_adjustments", default: [], null: false + t.bigint "trader_id", null: false + t.bigint "trading_strategy_id" + t.datetime "updated_at", null: false + t.index ["trader_id", "reflection_period_start", "reflection_period_end"], name: "index_trader_reflections_on_trader_and_period", unique: true + t.index ["trader_id"], name: "index_trader_reflections_on_trader_id" + t.index ["trading_strategy_id"], name: "index_trader_reflections_on_trading_strategy_id" + end + create_table "trader_trades", force: :cascade do |t| t.string "action", null: false t.bigint "allocation_decision_id", null: false @@ -293,8 +347,12 @@ add_foreign_key "factor_values", "factor_definitions" add_foreign_key "portfolio_snapshots", "allocation_tasks" add_foreign_key "portfolio_snapshots", "traders" + add_foreign_key "strategy_adjustment_logs", "trader_reflections" + add_foreign_key "strategy_adjustment_logs", "trading_strategies" add_foreign_key "trader_positions", "assets" add_foreign_key "trader_positions", "traders" + add_foreign_key "trader_reflections", "traders" + add_foreign_key "trader_reflections", "trading_strategies" add_foreign_key "trader_trades", "allocation_decisions" add_foreign_key "trader_trades", "allocation_tasks" add_foreign_key "trader_trades", "assets" diff --git a/docs/trader_reflection_report.md b/docs/trader_reflection_report.md new file mode 100644 index 0000000..931377b --- /dev/null +++ b/docs/trader_reflection_report.md @@ -0,0 +1,221 @@ +# Trader 反思报告方案 + +本文档描述 SmartTrader 中 `Trader Reflection Report` 的设计与最小可行版本实现方案。 + +## 目标 + +为每个 trader 增加一层“交易反思”能力。 + +系统基于以下数据: + +- 当前策略参数 +- 最近一段时间的交易记录 +- allocation decision / task 执行结果 +- 组合净值快照 +- 当前持仓与浮盈亏 + +通过 LLM 输出一份结构化反思报告,用来回答: + +- 最近做得好的地方是什么 +- 最近做错的地方是什么 +- 哪些行为模式值得警惕 +- 是否建议微调策略 +- 如果要微调,优先动哪些参数 + +## 为什么先做“报告”,不直接自动改策略 + +第一版不应直接让 LLM 自动修改策略。 + +原因: + +- 短期盈亏噪声很大,直接自动调参容易过拟合 +- 需要先验证 LLM 反思输出是否稳定、可解释 +- 需要为后续“策略微调”保留人工确认和变更记录 + +因此 MVP 只做: + +- 生成反思报告 +- 展示反思报告 +- 提供结构化的建议调整项 + +不做: + +- 自动写回 `trading_strategies` +- 自动替换 strategy description +- 自动改变风险等级 + +## MVP 范围 + +第一版反思报告仅覆盖: + +1. trader 当前策略上下文 +2. 最近 30 天交易流水 +3. 最近 30 天 allocation task 执行结果 +4. 最近组合净值与累计盈亏 +5. 当前持仓浮盈亏 + +输出内容包括: + +- 总结摘要 +- 做得好的地方 +- 主要问题 +- 行为模式观察 +- 风险提示 +- 建议调整项 +- 是否建议继续当前策略 + +## 数据来源 + +反思报告主要依赖现有表: + +- `trading_strategies` +- `trader_trades` +- `allocation_decisions` +- `allocation_tasks` +- `portfolio_snapshots` +- `trader_positions` + +不需要修改交易执行引擎,也不需要先做 realized P&L 精确核算引擎。 + +## 建议新增模型 + +建议新增: + +### `trader_reflections` + +字段建议: + +- `trader_id` +- `trading_strategy_id` +- `reflection_period_start` +- `reflection_period_end` +- `status` +- `source` +- `metrics` +- `llm_summary` +- `findings` +- `suggested_adjustments` +- `prompt_version` +- `generated_at` +- `error_message` + +说明: + +- `metrics` 保存结构化统计输入摘要 +- `findings` 保存 strengths / mistakes / patterns / risk_issues +- `suggested_adjustments` 保存参数建议 +- `status` 至少支持 `pending / generated / failed` + +## LLM 输出格式 + +第一版不要只存自由文本,建议要求 LLM 返回 JSON。 + +建议结构: + +```json +{ + "summary": "对最近交易表现的简短总结", + "strengths": ["做得好的地方 1", "做得好的地方 2"], + "mistakes": ["主要问题 1", "主要问题 2"], + "pattern_findings": ["观察到的行为模式 1"], + "risk_issues": ["风险点 1"], + "suggested_adjustments": [ + { + "parameter": "buy_signal_threshold", + "direction": "increase", + "reason": "最近买入门槛偏低,导致追高" + } + ], + "recommendation": "是否建议维持当前策略或进行微调" +} +``` + +## 策略调整边界 + +反思报告里允许建议,但不自动应用。 + +建议允许被提及的参数仅限: + +- `max_positions` +- `buy_signal_threshold` +- `max_position_size` +- `min_cash_reserve` + +暂不允许: + +- 修改 trader 描述 +- 自动改风险等级 +- 自动生成全新策略文本 + +## 页面入口 + +MVP 建议直接挂在 trader 详情页: + +- 增加“生成反思报告”按钮 +- 展示最近一份反思报告 +- 可列出最近几份历史报告 + +这样用户可以围绕某个 trader 看完整闭环: + +- 当前策略 +- 当前持仓 +- 最近交易 +- 最近执行 +- 最新反思 + +## 服务设计 + +建议新增: + +### `TraderReflectionService` + +职责: + +- 收集 trader 过去一段时间的交易和快照 +- 计算基础统计指标 +- 组织 LLM prompt +- 解析结构化 JSON +- 写入 `TraderReflection` + +服务输出: + +- 一条 `TraderReflection` 记录 + +### 可选后续扩展 + +后续可新增: + +- `GenerateTraderReflectionJob` +- `ApplyTraderStrategyAdjustmentService` + +但不在第一版实现范围内。 + +## MVP 成功标准 + +第一版上线后,满足以下条件即可认为成功: + +1. 用户可以在 trader 页面生成反思报告 +2. 报告能够落库 +3. 报告包含结构化结论,不只是原始文本 +4. 报告能指出至少一类行为问题或风险提示 +5. 不会自动更改 trader 策略 + +## 第二阶段再做什么 + +在反思报告稳定后,再考虑: + +1. 加入“人工确认后应用调整” +2. 记录策略变更前后参数 +3. 引入策略版本历史 +4. 对建议值增加安全边界约束 +5. 引入 realized P&L 更精确计算 + +## 当前实现策略 + +本仓库第一版将按以下路径落地: + +1. 新建 `TraderReflection` 模型与表 +2. 新建 `TraderReflectionService` +3. 在 `traders/:id` 页面加“生成反思报告”按钮 +4. 展示最新一份报告 +5. 仅做只读反思,不自动调策略