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} - 交易反思报告" %>
+
+
+
+
+
+
+
+ <% if notice.present? %>
+
+
+ <%= notice %>
+
+ <% end %>
+
+ <% if alert.present? %>
+
+
+ <%= alert %>
+
+ <% end %>
+
+
+
+
+
<%= @trader.name %>
+
+
+
+ <%= @trader_reflection.reflection_period_start %> 至 <%= @trader_reflection.reflection_period_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 %>
+
+ | <%= 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 %>
+ |
+
+ <% end %>
+
+
+
+ <% else %>
+
+
+
当前没有建议微调的参数,说明这次反思更偏向复盘和观察。
+
+ <% end %>
+
+
+
+ <% if @trader_reflection.strategy_adjustment_logs.any? %>
+
+
+
+
+
+
+ | 时间 |
+ 参数 |
+ 方向 |
+ 变更 |
+ 原因 |
+
+
+
+ <% @trader_reflection.strategy_adjustment_logs.order(applied_at: :desc).each do |log| %>
+
+ | <%= 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 %>
+
+
+
+
+ <% 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 %>
+
+
+
+
+ 交易反思报告
+
+
+
+
+
+ <% if @latest_trader_reflection&.generated? %>
+ <% findings = @latest_trader_reflection.findings || {} %>
+ <% suggested_adjustments = Array(@latest_trader_reflection.suggested_adjustments) %>
+
+
+
+
+ <% 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. 仅做只读反思,不自动调策略