diff --git a/Gemfile b/Gemfile index b497c02..f436f67 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,9 @@ gem "view_component" # Pagination gem "kaminari" +# Markdown rendering +gem "kramdown" + # Google Sign-In authentication gem "google_sign_in" diff --git a/Gemfile.lock b/Gemfile.lock index 16df900..75dbf25 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -240,6 +240,8 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) + kramdown (2.5.2) + rexml (>= 3.4.4) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -403,6 +405,7 @@ GEM reline (0.6.3) io-console (~> 0.5) retriable (3.2.1) + rexml (3.4.4) rice (4.6.1) rubocop (1.85.0) json (~> 2.3) @@ -551,6 +554,7 @@ DEPENDENCIES jbuilder kamal kaminari + kramdown pg (~> 1.1) propshaft pry-byebug @@ -655,6 +659,7 @@ CHECKSUMS kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -729,6 +734,7 @@ CHECKSUMS regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 retriable (3.2.1) sha256=26e87a33391fae4c382d4750f1e135e4dda7e5aa32b6b71f1992265981f9b991 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rice (4.6.1) sha256=aeee1ccc8f918498245161e03e7fbd14341ad04126565ae8de784c444bac477d rubocop (1.85.0) sha256=317407feb681a07d54f64d2f9e1d6b6af1ce7678e51cd658e3ad8bd66da48c01 rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd diff --git a/app/assets/stylesheets/allocation_decisions.css b/app/assets/stylesheets/allocation_decisions.css.bak2 similarity index 100% rename from app/assets/stylesheets/allocation_decisions.css rename to app/assets/stylesheets/allocation_decisions.css.bak2 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 11b1ee0..f75616d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2,11 +2,9 @@ * This is a manifest file that'll be compiled into application.css. * * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * application-wide styles in this file, but keep in mind that CSS precedence will follow standard + * cascading order, meaning styles declared later in this document or manifest will override earlier ones, * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. */ /* Import page-specific styles */ @@ -20,3 +18,4 @@ @import "allocation_decisions"; @import "job_executions"; @import "trader_reflections"; +@import "daily_reports"; diff --git a/app/assets/stylesheets/assets.css b/app/assets/stylesheets/assets.css index 8de0bd2..62a3b5b 100644 --- a/app/assets/stylesheets/assets.css +++ b/app/assets/stylesheets/assets.css @@ -1591,3 +1591,369 @@ grid-template-columns: 1fr; } } + +/* ============ Asset Detail Page ============ */ + +/* Asset Detail Container */ +.asset-detail { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Asset Detail Section */ +.asset-detail__section { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 20px; + padding: 1.75rem; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.asset-detail__section-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Asset Info Grid */ +.asset-detail__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.asset-detail__item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.asset-detail__label { + font-size: 0.75rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.asset-detail__value { + font-size: 1rem; + font-weight: 500; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.asset-detail__value.positive { + color: #10b981; +} + +.asset-detail__value.negative { + color: #ef4444; +} + +/* Asset Description */ +.asset-detail__description { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.asset-detail__description-title { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 0.75rem; +} + +.asset-detail__description-text { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-text-primary); +} + +/* Price Cards Grid */ +.price-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.price-card { + padding: 1.25rem; + background: linear-gradient(135deg, rgba(8, 12, 24, 0.96), rgba(15, 23, 42, 0.88)); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.price-card--primary { + border-color: rgba(6, 182, 212, 0.3); + background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(8, 12, 24, 0.96)); +} + +.price-card--positive { + border-color: rgba(16, 185, 129, 0.3); + background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(8, 12, 24, 0.96)); +} + +.price-card--negative { + border-color: rgba(239, 68, 68, 0.3); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.08), rgba(8, 12, 24, 0.96)); +} + +.price-card__label { + font-size: 0.6875rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.price-card__value { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.price-card__value.large { + font-size: 1.75rem; +} + +.price-card__value.positive { + color: #10b981; +} + +.price-card__value.negative { + color: #ef4444; +} + +/* Timeframe Selector */ +.timeframe-selector { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; + padding: 0.5rem; + background: rgba(17, 24, 39, 0.5); + border: 1px solid var(--color-border); + border-radius: 12px; +} + +.timeframe-selector__label { + font-size: 0.8125rem; + color: var(--color-text-secondary); + margin-right: 0.5rem; +} + +.timeframe-btn { + display: inline-flex; + align-items: center; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + transition: all var(--transition-fast); +} + +.timeframe-btn--active { + background: linear-gradient(135deg, var(--color-accent-gradient-start), var(--color-accent-gradient-end)); + color: white; +} + +.timeframe-btn--inactive { + color: var(--color-text-secondary); + background: transparent; +} + +.timeframe-btn--inactive:hover { + color: var(--color-accent-primary); + background: rgba(6, 182, 212, 0.1); +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1.5rem; + text-align: center; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.25rem; + background: rgba(6, 182, 212, 0.03); + border: 1px solid rgba(6, 182, 212, 0.1); + border-radius: 12px; +} + +.stat-item__value { + font-size: 2rem; + font-weight: 700; + color: var(--color-text-primary); + line-height: 1; +} + +.stat-item__label { + font-size: 0.75rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Asset Type Badge */ +.asset-type-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.asset-type-badge--crypto { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; +} + +.asset-type-badge--stock { + background: rgba(6, 182, 212, 0.15); + color: var(--color-accent-primary); +} + +.asset-type-badge--forex { + background: rgba(16, 185, 129, 0.15); + color: #10b981; +} + +.asset-type-badge--commodity { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +/* Data Table */ +.data-table-wrapper { + overflow-x: auto; + border-radius: 12px; + background: rgba(17, 24, 39, 0.5); + border: 1px solid var(--color-border); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.data-table thead { + background: rgba(6, 182, 212, 0.05); +} + +.data-table th { + padding: 0.875rem 1rem; + text-align: left; + font-weight: 600; + color: var(--color-text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--color-border); +} + +.data-table th.text-right { + text-align: right; +} + +.data-table tbody tr { + border-bottom: 1px solid rgba(71, 85, 105, 0.3); + transition: background var(--transition-fast); +} + +.data-table tbody tr:hover { + background: rgba(6, 182, 212, 0.05); +} + +.data-table tbody tr:last-child { + border-bottom: none; +} + +.data-table td { + padding: 0.875rem 1rem; + color: var(--color-text-primary); +} + +.data-table td.text-right { + text-align: right; +} + +.data-table td.positive { + color: #10b981; +} + +.data-table td.negative { + color: #ef4444; +} + +.data-table .text-muted { + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.data-table strong { + color: var(--color-text-primary); + font-weight: 600; +} + +/* Back Link */ +.back-link { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: color var(--transition-fast); + margin-bottom: 1.5rem; + display: inline-flex; +} + +.back-link:hover { + color: var(--color-accent-primary); +} + +.back-link svg { + width: 18px; + height: 18px; +} + +/* Asset Actions */ +.asset-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +/* Empty State */ +.strategy-empty { + text-align: center; + padding: 2rem; + color: var(--color-text-secondary); +} + +.strategy-empty p { + margin-bottom: 1rem; +} diff --git a/app/assets/stylesheets/daily_reports.css b/app/assets/stylesheets/daily_reports.css new file mode 100644 index 0000000..e4b76a1 --- /dev/null +++ b/app/assets/stylesheets/daily_reports.css @@ -0,0 +1,1160 @@ +/* ============================================ + SmartTrader Daily Reports - Modern FinTech Console + ============================================ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap'); + +:root { + /* Primary Colors */ + --color-primary: #6366f1; + --color-primary-light: #818cf8; + --color-primary-dark: #4f46e5; + --color-primary-glow: rgba(99, 102, 241, 0.4); + + /* Status Colors */ + --color-success: #34d399; + --color-success-glow: rgba(52, 211, 153, 0.3); + --color-warning: #fbbf24; + --color-warning-glow: rgba(251, 191, 36, 0.3); + --color-danger: #ef4444; + --color-danger-glow: rgba(239, 68, 68, 0.3); + --color-info: #38bdf8; + --color-info-glow: rgba(56, 189, 248, 0.3); + + /* Background Colors */ + --bg-primary: rgba(15, 23, 42, 0.95); + --bg-secondary: rgba(30, 41, 59, 0.9); + --bg-tertiary: rgba(51, 65, 85, 0.6); + --bg-glass: rgba(30, 41, 59, 0.7); + + /* Border Colors */ + --border-primary: rgba(99, 102, 241, 0.3); + --border-secondary: rgba(71, 85, 105, 0.4); + --border-glow: rgba(99, 102, 241, 0.5); + + /* Typography */ + --font-display: 'JetBrains Mono', 'Fira Code', monospace; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-2xl: 48px; + + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; + --transition-slow: 0.4s ease; +} + +/* ============================================ + Base & Layout + ============================================ */ + +.reports-container, +.report-detail-container { + font-family: var(--font-body); + min-height: 100vh; + position: relative; + background: linear-gradient(180deg, var(--bg-primary) 0%, #0f172a 100%); + padding: var(--spacing-lg); +} + +/* Scanline Effect */ +.reports-container::before, +.report-detail-container::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + z-index: 1; +} + +/* Grid Background */ +.reports-container::after, +.report-detail-container::after { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px); + background-size: 40px 40px; + z-index: 0; +} + +/* ============================================ + Typography + ============================================ */ + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.02em; +} + +/* ============================================ + Header + ============================================ */ + +.reports-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-xl); + position: relative; + z-index: 2; + animation: slideDown 0.5s ease-out; +} + +.reports-header__left { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.reports-header h1 { + font-size: 28px; + font-weight: 700; + color: #f1f5f9; + text-transform: uppercase; + letter-spacing: 0.05em; + text-shadow: 0 0 20px var(--color-primary-glow); +} + +.reports-header__right { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +/* ============================================ + Back Link + ============================================ */ + +.back-link { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-primary-light); + text-decoration: none; + font-family: var(--font-display); + font-size: 13px; + font-weight: 500; + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(99, 102, 241, 0.1); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + + &:hover { + background: var(--color-primary-glow); + border-color: var(--color-primary-light); + transform: translateX(-4px); + box-shadow: 0 0 20px var(--color-primary-glow); + } + + svg { + width: 16px; + height: 16px; + } +} + +/* ============================================ + Generate Form + ============================================ */ + +.reports-generate-form { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--bg-glass); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-md); + backdrop-filter: blur(10px); +} + +.date-input { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-sm); + color: #e2e8f0; + font-family: var(--font-display); + font-size: 14px; + min-width: 140px; + transition: all var(--transition-fast); + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); + } + + &::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(1.5); + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + } + } +} + +/* ============================================ + Report Cards + ============================================ */ + +.reports-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + position: relative; + z-index: 2; +} + +.report-card { + position: relative; + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(30, 41, 59, 0.7) 100%); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + display: grid; + grid-template-columns: 160px 1fr auto; + gap: var(--spacing-md); + align-items: center; + transition: all var(--transition-normal); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--color-primary), transparent); + opacity: 0; + transition: opacity var(--transition-normal); + } + + &:hover { + border-color: var(--border-glow); + transform: translateX(8px); + box-shadow: + 0 4px 20px rgba(0, 0, 0, 0.3), + 0 0 30px rgba(99, 102, 241, 0.1); + + &::before { + opacity: 1; + } + } + + &.draft { + opacity: 0.6; + border-style: dashed; + border-color: var(--color-warning); + + &::before { + background: linear-gradient(90deg, transparent, var(--color-warning), transparent); + } + } + + &__date { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + display: flex; + flex-direction: column; + gap: 4px; + + &::after { + content: attr(data-day); + font-size: 11px; + color: var(--color-primary-light); + font-weight: 400; + letter-spacing: 0.1em; + text-transform: uppercase; + } + } + + &__summary { + color: #94a3b8; + font-size: 14px; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__meta { + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + &__time { + font-family: var(--font-display); + font-size: 12px; + color: var(--color-info); + padding: 4px 8px; + background: rgba(56, 189, 248, 0.1); + border-radius: var(--radius-sm); + } + + &__status { + padding: 4px 12px; + border-radius: 20px; + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.published { + background: var(--color-success-glow); + color: var(--color-success); + border: 1px solid rgba(52, 211, 153, 0.3); + } + + &.draft { + background: var(--color-warning-glow); + color: var(--color-warning); + border: 1px solid rgba(251, 191, 36, 0.3); + } + } + + &__actions { + display: flex; + gap: var(--spacing-sm); + } +} + +/* ============================================ + Empty State + ============================================ */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120px var(--spacing-lg); + color: #64748b; + text-align: center; + position: relative; + z-index: 2; + + svg { + width: 80px; + height: 80px; + margin-bottom: var(--spacing-lg); + opacity: 0.4; + stroke: var(--color-primary); + filter: drop-shadow(0 0 20px var(--color-primary-glow)); + } + + p { + font-size: 16px; + margin-bottom: var(--spacing-sm); + color: #94a3b8; + } + + &__hint { + font-size: 14px; + opacity: 0.6; + font-family: var(--font-display); + } +} + +/* ============================================ + Report Detail + ============================================ */ + +.report-detail-container { + max-width: 1000px; + margin: 0 auto; +} + +.report-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-2xl); + position: relative; + z-index: 2; + animation: fadeIn 0.6s ease-out; + + &__left { + display: flex; + align-items: center; + gap: var(--spacing-lg); + } + + &__title-group { + h1 { + font-size: 32px; + font-weight: 700; + margin: 0 0 var(--spacing-sm) 0; + background: linear-gradient(135deg, #f1f5f9 0%, #94a3b8 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .report-date { + font-family: var(--font-display); + color: var(--color-primary-light); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + } + } + + &__actions { + display: flex; + gap: var(--spacing-sm); + } +} + +/* ============================================ + Report Summary + ============================================ */ + +.report-detail-summary { + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(51, 65, 85, 0.5) 100%); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + position: relative; + z-index: 2; + overflow: hidden; + animation: slideUp 0.5s ease-out 0.1s backwards; + + &::before { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 200%; + background: radial-gradient(circle, var(--color-primary-glow) 0%, transparent 70%); + opacity: 0.1; + } + + h3 { + font-family: var(--font-display); + font-size: 12px; + color: var(--color-primary-light); + margin-bottom: var(--spacing-md); + text-transform: uppercase; + letter-spacing: 0.15em; + } + + p { + color: #e2e8f0; + line-height: 1.7; + font-size: 16px; + position: relative; + } +} + +/* ============================================ + Report Content (Markdown) + ============================================ */ + +.report-detail-content { + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + position: relative; + z-index: 2; + animation: slideUp 0.5s ease-out 0.2s backwards; + + .markdown-body { + color: #e2e8f0; + line-height: 1.8; + font-size: 15px; + + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + font-weight: 600; + margin-top: var(--spacing-xl); + margin-bottom: var(--spacing-md); + } + + h1 { + font-size: 24px; + border-bottom: 1px solid var(--border-secondary); + padding-bottom: var(--spacing-md); + } + + h2 { + font-size: 20px; + color: var(--color-primary-light); + } + + h3 { + font-size: 18px; + color: #cbd5e1; + } + + h4 { + font-size: 16px; + } + + p { + margin-bottom: var(--spacing-md); + } + + ul, ol { + margin-bottom: var(--spacing-md); + padding-left: var(--spacing-xl); + + li { + margin-bottom: var(--spacing-sm); + position: relative; + + &::marker { + color: var(--color-primary); + } + } + } + + code { + font-family: var(--font-display); + background: rgba(0, 0, 0, 0.4); + padding: 2px 8px; + border-radius: 4px; + font-size: 0.9em; + color: var(--color-warning); + border: 1px solid rgba(251, 191, 36, 0.2); + } + + pre { + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + overflow-x: auto; + margin: var(--spacing-lg) 0; + position: relative; + + &::before { + content: attr(data-language); + position: absolute; + top: 0; + right: 0; + padding: 4px 12px; + font-family: var(--font-display); + font-size: 11px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + code { + background: none; + padding: 0; + color: #e2e8f0; + font-size: 13px; + line-height: 1.6; + border: none; + } + } + + blockquote { + border-left: 4px solid var(--color-primary); + padding: var(--spacing-md) var(--spacing-lg); + background: rgba(99, 102, 241, 0.05); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + margin: var(--spacing-lg) 0; + color: #94a3b8; + font-style: italic; + } + + table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: var(--spacing-lg) 0; + border-radius: var(--radius-md); + overflow: hidden; + + thead { + background: linear-gradient(180deg, var(--bg-tertiary) 0%, rgba(51, 65, 85, 0.5) 100%); + } + + th, td { + padding: var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--border-secondary); + } + + th { + font-family: var(--font-display); + font-size: 12px; + font-weight: 600; + color: var(--color-primary-light); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + td { + font-size: 14px; + } + + tr { + transition: background var(--transition-fast); + + &:hover { + background: var(--color-primary-glow); + } + } + } + + hr { + border: none; + border-top: 1px solid var(--border-secondary); + margin: var(--spacing-2xl) 0; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + background: var(--bg-secondary); + border: 1px solid var(--color-primary); + border-radius: 50%; + z-index: 1; + } + + &::after { + content: '◆'; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: var(--color-primary); + z-index: 2; + font-size: 12px; + } + } + } +} + +/* ============================================ + Report Statistics + ============================================ */ + +.report-detail-stats { + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(51, 65, 85, 0.4) 100%); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + position: relative; + z-index: 2; + animation: slideUp 0.5s ease-out 0.3s backwards; + + h3 { + font-family: var(--font-display); + font-size: 12px; + color: var(--color-primary-light); + margin-bottom: var(--spacing-lg); + text-transform: uppercase; + letter-spacing: 0.15em; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-lg); + } + + .stat-item { + position: relative; + padding: var(--spacing-md); + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-lg); + transition: all var(--transition-fast); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 3px; + height: 100%; + background: linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-light) 100%); + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + opacity: 0; + transition: opacity var(--transition-fast); + } + + &:hover { + border-color: var(--border-glow); + transform: translateY(-2px); + + &::before { + opacity: 1; + } + } + } + + .stat-label { + font-size: 12px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--spacing-sm); + } + + .stat-value { + font-family: var(--font-display); + font-size: 20px; + color: #f1f5f9; + font-weight: 600; + } +} + +/* ============================================ + Report Meta + ============================================ */ + +.report-detail-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + padding: var(--spacing-lg); + background: rgba(15, 23, 42, 0.5); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-xl); + position: relative; + z-index: 2; + animation: fadeIn 0.5s ease-out 0.4s backwards; + + .meta-item { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + + .meta-label { + font-family: var(--font-display); + font-size: 11px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .meta-value { + color: #e2e8f0; + font-size: 14px; + + &.published { + color: var(--color-success); + } + + &.draft { + color: var(--color-warning); + } + } + } +} + +/* ============================================ + Buttons + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; + transition: all var(--transition-fast); + font-family: var(--font-body); + + svg { + width: 16px; + height: 16px; + } + + &.btn-primary { + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%); + color: white; + border: 1px solid transparent; + box-shadow: 0 0 20px var(--color-primary-glow); + + &:hover { + background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); + transform: translateY(-2px); + box-shadow: 0 0 30px var(--color-primary-glow); + } + + &:active { + transform: translateY(0); + } + } + + &.btn-secondary { + background: var(--bg-tertiary); + color: #e2e8f0; + border: 1px solid var(--border-secondary); + + &:hover { + background: var(--bg-secondary); + border-color: var(--border-glow); + transform: translateY(-1px); + } + } + + &.btn-danger { + background: rgba(239, 68, 68, 0.1); + color: var(--color-danger); + border: 1px solid rgba(239, 68, 68, 0.3); + + &:hover { + background: var(--color-danger-glow); + border-color: var(--color-danger); + } + } + + &.btn-sm { + padding: 6px 12px; + font-size: 13px; + } +} + +/* ============================================ + Alerts + ============================================ */ + +.alert { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-lg); + font-size: 14px; + position: relative; + z-index: 2; + animation: slideDown 0.3s ease-out; + + &.alert-success { + background: var(--color-success-glow); + border: 1px solid rgba(52, 211, 153, 0.3); + color: var(--color-success); + + &::before { + content: '✓'; + font-family: var(--font-display); + font-weight: bold; + font-size: 18px; + } + } + + &.alert-error { + background: var(--color-danger-glow); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--color-danger); + + &::before { + content: '✕'; + font-family: var(--font-display); + font-weight: bold; + font-size: 18px; + } + } +} + +/* ============================================ + Factor Report Button (Embedded) + ============================================ */ + +.factors-header__report-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(99, 102, 241, 0.1); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--color-primary-light); + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: all var(--transition-fast); + + &:hover { + background: var(--color-primary-glow); + border-color: var(--color-primary-light); + transform: translateY(-2px); + box-shadow: 0 0 20px var(--color-primary-glow); + } + + svg { + width: 16px; + height: 16px; + } + + &--generate { + background: rgba(16, 185, 129, 0.1); + border-color: rgba(16, 185, 129, 0.3); + color: var(--color-success); + + &:hover { + background: var(--color-success-glow); + border-color: var(--color-success); + box-shadow: 0 0 20px var(--color-success-glow); + } + } +} + +/* ============================================ + Pagination + ============================================ */ + +.pagination { + display: flex; + justify-content: center; + padding: var(--spacing-xl) 0; + position: relative; + z-index: 2; + + a, span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 40px; + padding: 0 var(--spacing-sm); + margin: 0 4px; + font-family: var(--font-display); + font-size: 14px; + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + } + + a { + background: var(--bg-tertiary); + color: #94a3b8; + text-decoration: none; + border: 1px solid var(--border-secondary); + + &:hover { + background: var(--bg-secondary); + color: #e2e8f0; + border-color: var(--border-glow); + } + + &.current, + &.current:hover { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); + box-shadow: 0 0 20px var(--color-primary-glow); + } + } + + span { + color: #64748b; + } +} + +/* ============================================ + Animations + ============================================ */ + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================ + Responsive Design + ============================================ */ + +@media (max-width: 768px) { + .reports-container, + .report-detail-container { + padding: var(--spacing-md); + } + + .reports-header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md); + } + + .reports-header h1 { + font-size: 22px; + } + + .report-card { + grid-template-columns: 1fr; + gap: var(--spacing-sm); + + &__summary { + -webkit-line-clamp: 3; + } + + &__meta { + flex-wrap: wrap; + } + } + + .report-detail-header { + flex-direction: column; + gap: var(--spacing-md); + + &__actions { + width: 100%; + justify-content: flex-start; + } + } + + .report-detail-stats .stats-grid { + grid-template-columns: 1fr; + } + + .report-detail-meta { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .reports-generate-form { + flex-direction: column; + width: 100%; + + .date-input, + .btn { + width: 100%; + } + } + + .report-card__actions { + width: 100%; + justify-content: flex-start; + + .btn { + flex: 1; + justify-content: center; + } + } +} + +/* ============================================ + Loading Skeleton + ============================================ */ + +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-tertiary) 0%, + rgba(51, 65, 85, 0.8) 50%, + var(--bg-tertiary) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.skeleton-card { + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + height: 100px; +} + +.skeleton-text { + height: 14px; + margin-bottom: var(--spacing-sm); +} + +.skeleton-title { + height: 20px; + width: 40%; + margin-bottom: var(--spacing-md); +} diff --git a/app/controllers/admin/factor_daily_reports_controller.rb b/app/controllers/admin/factor_daily_reports_controller.rb new file mode 100644 index 0000000..fb70be8 --- /dev/null +++ b/app/controllers/admin/factor_daily_reports_controller.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Admin + class FactorDailyReportsController < ApplicationController + before_action :require_user + before_action :set_report, only: %i[show edit update destroy regenerate] + + REPORT_TYPE = 'factor'.freeze + + def index + @reports = DailyReport.factor_reports + .recent + .page(params[:page]) + .per(20) + end + + def show + @report.update!(published: true) unless @report.published? + end + + def new + @report = DailyReport.new(report_type: REPORT_TYPE) + end + + def create + @report = DailyReport.new(report_params.merge(report_type: REPORT_TYPE)) + + if @report.save + redirect_to admin_factor_daily_report_path(@report), notice: '日报创建成功' + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @report.update(report_params) + redirect_to admin_factor_daily_report_path(@report), notice: '日报更新成功' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @report.destroy + redirect_to admin_factor_daily_reports_path, notice: '日报已删除' + end + + # 手动生成日报 + def generate + date = params[:date]&.to_date || Date.current + + # 检查是否已存在 + existing = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + if existing + redirect_to admin_factor_daily_report_path(existing), alert: '该日期的日报已存在' + return + end + + GenerateFactorDailyReportJob.perform_now(date:) + report = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + + if report + redirect_to admin_factor_daily_report_path(report), notice: '日报生成成功' + else + redirect_to admin_factor_daily_reports_path, alert: '日报生成失败' + end + end + + # 重新生成日报 + def regenerate + date = @report.report_date + GenerateFactorDailyReportJob.perform_now(date:) + report = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + + if report + redirect_to admin_factor_daily_report_path(report), notice: '日报重新生成成功' + else + redirect_to admin_factor_daily_report_path(@report), alert: '日报重新生成失败' + end + end + + private + + def set_report + @report = DailyReport.find(params[:id]) + end + + def report_params + params.require(:daily_report).permit( + :content, + :summary, + :published + ) + end + end +end diff --git a/app/controllers/admin/factor_definitions_controller.rb b/app/controllers/admin/factor_definitions_controller.rb index def117b..3bcea3c 100644 --- a/app/controllers/admin/factor_definitions_controller.rb +++ b/app/controllers/admin/factor_definitions_controller.rb @@ -7,6 +7,7 @@ class FactorDefinitionsController < ApplicationController def index @factors = FactorDefinition.ordered + @latest_report = DailyReport.latest_for_type('factor') end def matrix diff --git a/app/controllers/admin/signal_daily_reports_controller.rb b/app/controllers/admin/signal_daily_reports_controller.rb new file mode 100644 index 0000000..e1db993 --- /dev/null +++ b/app/controllers/admin/signal_daily_reports_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Admin + class SignalDailyReportsController < ApplicationController + before_action :require_user + before_action :set_report, only: %i[show edit update destroy regenerate] + + REPORT_TYPE = 'signal'.freeze + + def index + @reports = DailyReport.signal_reports + .recent + .page(params[:page]) + .per(20) + end + + def show + @report.update!(published: true) unless @report.published? + end + + def new + @report = DailyReport.new(report_type: REPORT_TYPE) + end + + def create + @report = DailyReport.new(report_params.merge(report_type: REPORT_TYPE)) + + if @report.save + redirect_to admin_signal_daily_report_path(@report), notice: '日报创建成功' + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @report.update(report_params) + redirect_to admin_signal_daily_report_path(@report), notice: '日报更新成功' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @report.destroy + redirect_to admin_signal_daily_reports_path, notice: '日报已删除' + end + + def generate + date = params[:date]&.to_date || Date.current + + existing = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + if existing + redirect_to admin_signal_daily_report_path(existing), alert: '该日期的日报已存在' + return + end + + GenerateSignalDailyReportJob.perform_now(date:) + report = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + + if report + redirect_to admin_signal_daily_report_path(report), notice: '日报生成成功' + else + redirect_to admin_signal_daily_reports_path, alert: '日报生成失败' + end + end + + def regenerate + date = @report.report_date + GenerateSignalDailyReportJob.perform_now(date:) + report = DailyReport.find_by(report_type: REPORT_TYPE, report_date: date) + + if report + redirect_to admin_signal_daily_report_path(report), notice: '日报重新生成成功' + else + redirect_to admin_signal_daily_report_path(@report), alert: '日报重新生成失败' + end + end + + private + + def set_report + @report = DailyReport.find(params[:id]) + end + + def report_params + params.require(:daily_report).permit( + :content, + :summary, + :published + ) + end + end +end diff --git a/app/controllers/admin/trading_signals_controller.rb b/app/controllers/admin/trading_signals_controller.rb index c70b2ac..a67c44b 100644 --- a/app/controllers/admin/trading_signals_controller.rb +++ b/app/controllers/admin/trading_signals_controller.rb @@ -28,6 +28,9 @@ def index hold: TradingSignal.hold_signals.count, high_confidence: TradingSignal.high_confidence.count } + + # 获取最新信号日报 + @latest_report = DailyReport.latest_for_type('signal') end def show; end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 37f03e7..d578bc2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,15 @@ module ApplicationHelper + # 渲染 Markdown 内容 + def render_markdown(text) + return '' if text.nil? + + require 'kramdown' + Kramdown::Document.new(text).to_html.html_safe + rescue LoadError + # 如果 kramdown 不可用,使用简单的格式化 + simple_format(text) + end + # 根据资产类型返回对应的样式类 def asset_type_badge(type) case type.to_s.downcase @@ -41,6 +52,20 @@ def status_badge_class(status) end end + # 格式化统计值 + def format_stat_value(value) + case value + when Hash + value.map { |k, v| "#{k}: #{format_stat_value(v)}" }.join(', ') + when Array + value.join(', ') + when TrueClass, FalseClass + value ? '是' : '否' + else + value.to_s + end + end + # Job 执行状态标签 def status_label(status) case status.to_s.downcase diff --git a/app/jobs/generate_factor_daily_report_job.rb b/app/jobs/generate_factor_daily_report_job.rb new file mode 100644 index 0000000..484d113 --- /dev/null +++ b/app/jobs/generate_factor_daily_report_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class GenerateFactorDailyReportJob < ApplicationJob + queue_as :reports + + def perform(date: Date.current) + Rails.logger.info("GenerateFactorDailyReportJob started for #{date}") + + service = FactorDailyReportGeneratorService.new(date: date) + report = service.generate + + if report&.persisted? + Rails.logger.info("Factor daily report generated successfully for #{date}") + report + else + Rails.logger.error("Failed to generate factor daily report for #{date}") + nil + end + rescue StandardError => e + Rails.logger.error("GenerateFactorDailyReportJob failed: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + raise + end +end diff --git a/app/jobs/generate_signal_daily_report_job.rb b/app/jobs/generate_signal_daily_report_job.rb new file mode 100644 index 0000000..ba11fc8 --- /dev/null +++ b/app/jobs/generate_signal_daily_report_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class GenerateSignalDailyReportJob < ApplicationJob + queue_as :reports + + def perform(date: Date.current) + Rails.logger.info("GenerateSignalDailyReportJob started for #{date}") + + service = SignalDailyReportGeneratorService.new(date: date) + report = service.generate + + if report&.persisted? + Rails.logger.info("Signal daily report generated successfully for #{date}") + report + else + Rails.logger.error("Failed to generate signal daily report for #{date}") + nil + end + rescue StandardError => e + Rails.logger.error("GenerateSignalDailyReportJob failed: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + raise + end +end diff --git a/app/models/daily_report.rb b/app/models/daily_report.rb new file mode 100644 index 0000000..17ce685 --- /dev/null +++ b/app/models/daily_report.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class DailyReport < ApplicationRecord + # 常量定义 + REPORT_TYPES = { + 'factor' => '因子日报', + 'signal' => '信号日报' + }.freeze + + # 验证 + validates :report_type, presence: true, inclusion: { in: REPORT_TYPES.keys } + validates :report_date, presence: true + validates :content, presence: true + + # Scopes + scope :factor_reports, -> { where(report_type: 'factor') } + scope :signal_reports, -> { where(report_type: 'signal') } + scope :published, -> { where(published: true) } + scope :draft, -> { where(published: false) } + scope :recent, -> { order(report_date: :desc, created_at: :desc) } + + # 类方法 + def self.types_for_select + REPORT_TYPES.map { |key, name| [name, key] } + end + + def self.latest_for_type(report_type) + where(report_type: report_type).recent.first + end + + # 实例方法 + def type_label + REPORT_TYPES[report_type] + end + + def factor_report? + report_type == 'factor' + end + + def signal_report? + report_type == 'signal' + end + + def published_label + published? ? '已发布' : '草稿' + end +end diff --git a/app/services/factor_daily_report_generator_service.rb b/app/services/factor_daily_report_generator_service.rb new file mode 100644 index 0000000..2c34c54 --- /dev/null +++ b/app/services/factor_daily_report_generator_service.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +# 因子日报生成服务 +class FactorDailyReportGeneratorService + FACTOR_REPORT_INSTRUCTIONS = <<~INSTRUCTIONS.freeze + 你是一个专业的金融数据分析助手,负责生成每日因子分析日报。 + + 日报格式要求: + 1. 使用 Markdown 格式 + 2. 包含以下章节: + - 今日概览(简要总结当日因子表现) + - 重点因子分析(突出表现异常或趋势明显的因子) + - 分行业/分类因子表现 + - 历史对比(与昨日/上周对比,如果数据足够) + - 建议与洞察 + + 语言风格: + - 专业、简洁、数据驱动 + - 使用中文输出 + - 突出关键数据和变化趋势 + INSTRUCTIONS + + def initialize(date: Date.current) + @date = date + @ai_service = AiChatService.new( + instructions: FACTOR_REPORT_INSTRUCTIONS, + temperature: 0.5, + max_tokens: 2000 + ) + end + + def generate + start_time = Time.current + + # 收集数据 + data = collect_data + + # 如果没有因子数据,生成一个空日报 + if data[:factors].empty? + content = build_empty_report + summary = "今日暂无因子数据" + return save_report(content, summary, data, start_time) + end + + # 构建 prompt + prompt = build_prompt(data) + + # 调用 AI 生成 + content = @ai_service.ask(prompt) + + # 生成摘要 + summary = generate_summary(content) + + # 保存日报 + save_report(content, summary, data, start_time) + rescue StandardError => e + Rails.logger.error("FactorDailyReportGeneratorService Error: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + nil + end + + private + + def collect_data + { + date: @date, + factors: FactorDefinition.active.ordered.to_a, + factor_values: FactorValue.where(calculated_at: @date.beginning_of_day...(@date + 1.day).beginning_of_day) + .includes(:asset, :factor_definition) + } + end + + def build_prompt(data) + # 格式化数据供 AI 分析 + formatted_data = format_data_for_ai(data) + + <<~PROMPT + 请基于以下数据生成 #{@date.strftime('%Y-%m-%d')} 的交易因子分析日报: + + ## 数据统计 + - 活跃因子数量: #{data[:factors].count} + - 因子值记录数: #{data[:factor_values].count} + + ## 因子分类统计 + #{factor_category_stats(data)} + + ## 因子表现数据(前50条记录) + #{formatted_data} + + 请按照指令要求的格式生成日报。 + PROMPT + end + + def format_data_for_ai(data) + values = data[:factor_values].take(50) + + values.map do |fv| + factor = fv.factor_definition + asset = fv.asset + value_display = fv.normalized_value.nil? ? 'N/A' : fv.normalized_value.round(4) + percentile_display = fv.percentile.nil? ? 'N/A' : fv.percentile.round(1) + + "#{asset.symbol} | #{factor.code}(#{factor.name}) | 标准化值: #{value_display} | 百分位: #{percentile_display}%" + end.join("\n") + end + + def factor_category_stats(data) + categories = data[:factors].group_by(&:category) + categories.map do |cat, factors| + "#{FactorDefinition::CATEGORIES[cat]}: #{factors.count}个因子" + end.join("\n") + end + + def save_report(content, summary, data, start_time) + generation_time_ms = ((Time.current - start_time) * 1000).to_i + + report = DailyReport.find_or_initialize_by( + report_type: 'factor', + report_date: @date + ) + + report.assign_attributes( + content: content, + summary: summary, + statistics: build_statistics(data), + generated_by: 'ai', + model_version: AiChatService::MODEL, + generation_time_ms: generation_time_ms + ) + + report.save! + report + end + + def build_statistics(data) + { + total_factors: data[:factors].count, + active_factors: data[:factors].count(&:active?), + total_assets: data[:factor_values].select(:asset_id).distinct.count, + factor_categories: data[:factors].group_by(&:category).transform_values(&:count), + total_values: data[:factor_values].count, + report_date: @date.to_s + } + end + + def generate_summary(content) + lines = content.split("\n").map(&:strip).reject(&:empty?) + # 找到第一个非标题行 + first_non_header = lines.find { |l| !l.start_with?('#') } + first_non_header || lines.first || "暂无摘要" + end + + def build_empty_report + <<~MARKDOWN + # #{@date.strftime('%Y年%m月%d日')} 交易因子日报 + + ## 今日概览 + + **暂无因子数据** + + 请先添加因子后再查看日报。 + + ## 建议与洞察 + + - 当前日期 #{@date.strftime('%Y-%m-%d')} 没有可用的因子数据 + - 建议在因子管理页面添加交易因子 + MARKDOWN + end +end diff --git a/app/services/signal_daily_report_generator_service.rb b/app/services/signal_daily_report_generator_service.rb new file mode 100644 index 0000000..d7f61fc --- /dev/null +++ b/app/services/signal_daily_report_generator_service.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# 信号日报生成服务 +class SignalDailyReportGeneratorService + SIGNAL_REPORT_INSTRUCTIONS = <<~INSTRUCTIONS.freeze + 你是一个专业的交易信号分析助手,负责生成每日交易信号日报。 + + 日报格式要求: + 1. 使用 Markdown 格式 + 2. 包含以下章节: + - 今日信号概览(总体信号分布) + - 强买入/卖出信号(高置信度信号) + - 重点关注资产(信号变化明显的资产) + - 行业/板块信号分布 + - 风险提示 + + 语言风格: + - 专业、客观、谨慎 + - 使用中文输出 + - 强调风险提示 + INSTRUCTIONS + + def initialize(date: Date.current) + @date = date + @ai_service = AiChatService.new( + instructions: SIGNAL_REPORT_INSTRUCTIONS, + temperature: 0.5, + max_tokens: 2000 + ) + end + + def generate + start_time = Time.current + data = collect_data + + # 如果没有信号数据,生成一个空日报 + if data[:signals].empty? + content = build_empty_report + summary = "今日暂无交易信号数据" + return save_report(content, summary, data, start_time) + end + + prompt = build_prompt(data) + content = @ai_service.ask(prompt) + summary = generate_summary(content) + save_report(content, summary, data, start_time) + rescue StandardError => e + Rails.logger.error("SignalDailyReportGeneratorService Error: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + nil + end + + private + + def collect_data + # 获取当天生成的信号 + signals = TradingSignal.where("DATE(generated_at) = ?", @date) + .includes(:asset) + .order(generated_at: :desc) + + { + date: @date, + signals: signals, + latest_signals: TradingSignal.includes(:asset) + .select("DISTINCT ON (asset_id) trading_signals.*") + .where("DATE(generated_at) <= ?", @date) + .order("asset_id, generated_at DESC") + } + end + + def build_prompt(data) + signals = data[:signals] + stats = calculate_stats(signals) + + <<~PROMPT + 请基于以下数据生成 #{@date.strftime('%Y-%m-%d')} 的交易信号分析日报: + + ## 信号统计 + - 总信号数: #{stats[:total]} + - 买入信号: #{stats[:buy]} + - 卖出信号: #{stats[:sell]} + - 持有信号: #{stats[:hold]} + - 高置信度: #{stats[:high_confidence]} + + ## 强信号资产(置信度 >= 70%) + #{strong_signals_list(signals)} + + ## 详细信号数据(前30条) + #{signals_data_formatted(signals.take(30))} + + 请按照指令要求的格式生成日报。 + PROMPT + end + + def calculate_stats(signals) + { + total: signals.count, + buy: signals.count { |s| s.buy? }, + sell: signals.count { |s| s.sell? }, + hold: signals.count { |s| s.hold? }, + high_confidence: signals.count { |s| s.confidence.to_f >= 0.7 } + } + end + + def strong_signals_list(signals) + strong = signals.select { |s| s.confidence.to_f >= 0.7 }.take(10) + + return "无" if strong.empty? + + strong.map do |s| + "#{s.asset.symbol} | #{s.signal_type_label} | 置信度: #{s.confidence_percentage}% | #{truncate_text(s.reasoning, 50)}" + end.join("\n") + end + + def signals_data_formatted(signals) + return "无数据" if signals.empty? + + signals.map do |s| + confidence_display = s.confidence.nil? ? 'N/A' : "#{s.confidence_percentage}%" + "#{s.asset.symbol} | #{s.signal_type_label} | 置信度: #{confidence_display} | #{truncate_text(s.reasoning, 60)}" + end.join("\n") + end + + def truncate_text(text, length) + return '' if text.nil? + text.length > length ? "#{text[0, length]}..." : text + end + + def save_report(content, summary, data, start_time) + generation_time_ms = ((Time.current - start_time) * 1000).to_i + + report = DailyReport.find_or_initialize_by( + report_type: 'signal', + report_date: @date + ) + + report.assign_attributes( + content: content, + summary: summary, + statistics: build_statistics(data), + generated_by: 'ai', + model_version: AiChatService::MODEL, + generation_time_ms: generation_time_ms + ) + + report.save! + report + end + + def build_statistics(data) + signals = data[:signals] + stats = calculate_stats(signals) + latest_signals = data[:latest_signals].to_a + + # 按信号类型统计最新信号 + latest_stats = { + total: latest_signals.size, + buy: latest_signals.count { |s| s.buy? }, + sell: latest_signals.count { |s| s.sell? }, + hold: latest_signals.count { |s| s.hold? } + } + + stats.merge(latest_signals: latest_stats, report_date: @date.to_s) + end + + def generate_summary(content) + lines = content.split("\n").map(&:strip).reject(&:empty?) + # 找到第一个非标题行 + first_non_header = lines.find { |l| !l.start_with?('#') } + first_non_header || lines.first || "暂无摘要" + end + + def build_empty_report + <<~MARKDOWN + # #{@date.strftime('%Y年%m月%d日')} 交易信号日报 + + ## 今日信号概览 + + **暂无交易信号数据** + + 请先生成交易信号后再查看日报。 + + ## 风险提示 + + - 当前日期 #{@date.strftime('%Y-%m-%d')} 没有可用的交易信号 + - 建先生成交易信号,日报将包含详细分析 + MARKDOWN + end +end diff --git a/app/views/admin/factor_daily_reports/edit.html.erb b/app/views/admin/factor_daily_reports/edit.html.erb new file mode 100644 index 0000000..443d67d --- /dev/null +++ b/app/views/admin/factor_daily_reports/edit.html.erb @@ -0,0 +1,45 @@ +<% content_for :title, "编辑因子日报 - #{@report.report_date}" %> + +
暂无日报记录
+点击上方"手动生成"按钮创建第一份日报
+<%= @report.summary %>
+