diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 07335e94..31750d8a 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2049,11 +2049,16 @@ z-index: 880; display: flex; flex-direction: column; - gap: 14px; - padding: 14px; + gap: 16px; + padding: 16px; border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 24px; background: + radial-gradient( + circle at 82% -10%, + rgba(59, 130, 246, 0.14), + transparent 34% + ), radial-gradient( circle at 8% 50%, rgba(34, 211, 238, 0.12), @@ -2066,170 +2071,527 @@ both; } -.root :global(.home-summary-grid) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.root :global(.home-summary-card) { - min-height: 106px; - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid rgba(148, 163, 184, 0.14); - background: rgba(8, 15, 28, 0.7); -} - -.root :global(.home-summary-card-head) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.root :global(.home-summary-card-head strong) { - color: #f8fafc; - font-size: 14px; - font-weight: 760; - line-height: 1.2; -} - -.root :global(.home-summary-card-body) { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - align-items: end; -} - -.root :global(.home-summary-stat) { - display: flex; - flex-direction: column; - gap: 4px; -} - -.root :global(.home-summary-stat b) { - color: #f8fafc; - font-size: 28px; - font-weight: 850; - line-height: 1; - letter-spacing: -0.04em; -} - -.root :global(.home-summary-stat span) { - color: rgba(148, 163, 184, 0.82); - font-size: 10px; - font-weight: 650; -} - -.root :global(.home-summary-stat b.accent-red) { - color: #fb7185; -} -.root :global(.home-summary-stat b.accent-amber) { - color: #fbbf24; -} -.root :global(.home-summary-stat b.accent-green) { - color: #4ade80; -} -.root :global(.home-summary-stat b.accent-cyan) { - color: #22d3ee; -} - -.root :global(.opportunity-strip-heading) { +.root :global(.opportunity-strip-topline) { display: flex; align-items: flex-start; justify-content: space-between; - gap: 16px; - padding: 4px 4px 0; + gap: 18px; } -.root :global(.opportunity-strip-heading > div) { +.root :global(.opportunity-strip-copy) { display: flex; flex-direction: column; - gap: 5px; + gap: 7px; min-width: 0; -} - -.root :global(.opportunity-strip-heading > div:first-child) { flex: 1; } -.root :global(.opportunity-strip-heading span) { - color: rgba(148, 163, 184, 0.82); +.root :global(.opportunity-strip-kicker) { + color: rgba(148, 163, 184, 0.8); font-size: 11px; - font-weight: 700; + font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } -.root :global(.opportunity-strip-heading strong) { +.root :global(.opportunity-strip-copy strong) { color: #f8fafc; - font-size: 17px; - font-weight: 820; + font-size: 19px; + font-weight: 860; line-height: 1.25; + letter-spacing: -0.02em; } -.root :global(.opportunity-strip-status) { +.root :global(.opportunity-strip-copy p) { + max-width: 620px; + color: rgba(191, 219, 254, 0.74); + font-size: 12px; + line-height: 1.6; +} + +.root :global(.opportunity-strip-tape) { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; justify-content: flex-end; - max-width: 360px; + max-width: 460px; } -.root :global(.opportunity-status-chip) { - display: inline-flex; - align-items: center; +.root :global(.opportunity-tape-pill) { + display: flex; + min-width: 92px; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(9, 16, 30, 0.7); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.root :global(.opportunity-tape-pill span) { + color: rgba(148, 163, 184, 0.82); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.root :global(.opportunity-tape-pill strong) { + color: #f8fafc; + font-size: 16px; + font-weight: 860; + line-height: 1; +} + +.root :global(.opportunity-tape-pill.accent-cyan strong) { + color: #67e8f9; +} + +.root :global(.opportunity-tape-pill.accent-green strong) { + color: #4ade80; +} + +.root :global(.opportunity-tape-pill.accent-slate strong) { + color: rgba(226, 232, 240, 0.78); +} + +.root :global(.opportunity-tape-pill.accent-outline) { + min-width: auto; justify-content: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(8, 15, 28, 0.78); - color: rgba(226, 232, 240, 0.88); + padding: 9px 11px; + background: rgba(6, 11, 23, 0.62); +} + +.root :global(.opportunity-tape-pill.accent-outline span) { + color: rgba(191, 219, 254, 0.92); font-size: 11px; - font-weight: 780; + letter-spacing: 0; + text-transform: none; +} + +.root :global(.opportunity-strip-main) { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.92fr); + gap: 14px; +} + +.root :global(.opportunity-hero-card) { + position: relative; + overflow: hidden; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 244px; + padding: 18px; + border: 1px solid rgba(56, 189, 248, 0.18); + border-radius: 22px; + background: + radial-gradient( + circle at 100% 0%, + rgba(14, 165, 233, 0.22), + transparent 28% + ), + radial-gradient( + circle at 12% 88%, + rgba(16, 185, 129, 0.1), + transparent 28% + ), + linear-gradient(135deg, rgba(7, 14, 28, 0.96), rgba(12, 27, 48, 0.92)); + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + box-shadow: + 0 18px 42px rgba(2, 6, 23, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.root :global(.opportunity-hero-card:hover) { + transform: translateY(-2px); + border-color: rgba(34, 211, 238, 0.36); + box-shadow: + 0 24px 52px rgba(2, 6, 23, 0.36), + 0 0 0 1px rgba(34, 211, 238, 0.08); +} + +.root :global(.opportunity-hero-header) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.root :global(.opportunity-hero-copy) { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; +} + +.root :global(.opportunity-hero-kicker) { + color: #67e8f9; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.opportunity-hero-title-row) { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.root :global(.opportunity-hero-title-row strong) { + overflow: hidden; + color: #f8fafc; + font-size: 28px; + font-weight: 880; + line-height: 1; + letter-spacing: -0.04em; + text-overflow: ellipsis; white-space: nowrap; } -.root :global(.opportunity-status-chip.live) { - border-color: rgba(74, 222, 128, 0.24); +.root :global(.opportunity-hero-date) { + color: rgba(186, 230, 253, 0.76); + font-size: 12px; + font-weight: 720; +} + +.root :global(.opportunity-hero-copy p) { + max-width: 520px; + color: rgba(226, 232, 240, 0.82); + font-size: 13px; + line-height: 1.55; +} + +.root :global(.opportunity-hero-tags) { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.root :global(.opportunity-hero-tag) { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 0 11px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(7, 13, 25, 0.58); + color: rgba(226, 232, 240, 0.92); + font-size: 11px; + font-weight: 820; + white-space: nowrap; +} + +.root :global(.opportunity-hero-tag.engine) { + border-color: rgba(34, 211, 238, 0.24); + color: #67e8f9; +} + +.root :global(.opportunity-hero-tag.signal-yes) { + border-color: rgba(74, 222, 128, 0.26); color: #86efac; } -.root :global(.opportunity-status-chip.pending) { +.root :global(.opportunity-hero-tag.signal-no) { + border-color: rgba(251, 113, 133, 0.24); + color: #fda4af; +} + +.root :global(.opportunity-hero-tag.signal-neutral) { + color: rgba(191, 219, 254, 0.88); +} + +.root :global(.opportunity-hero-tag.risk.high) { + border-color: rgba(251, 113, 133, 0.24); + color: #fda4af; +} + +.root :global(.opportunity-hero-tag.risk.medium) { border-color: rgba(251, 191, 36, 0.24); color: #fcd34d; } -.root :global(.opportunity-status-chip.tradable) { - border-color: rgba(34, 211, 238, 0.26); - color: #67e8f9; +.root :global(.opportunity-hero-tag.risk.low) { + border-color: rgba(74, 222, 128, 0.24); + color: #86efac; } -.root :global(.opportunity-status-chip.side) { - color: rgba(191, 219, 254, 0.9); +.root :global(.opportunity-hero-body) { + display: grid; + gap: 14px; } -.root :global(.opportunity-status-chip.muted) { - color: rgba(148, 163, 184, 0.88); +.root :global(.opportunity-hero-edgeblock) { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(34, 211, 238, 0.18); + background: + linear-gradient( + 135deg, + rgba(8, 15, 28, 0.84), + rgba(13, 30, 52, 0.82) + ); } -.root :global(.opportunity-card-grid) { +.root :global(.opportunity-hero-edgeblock span) { + color: rgba(148, 163, 184, 0.8); + font-size: 11px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.opportunity-hero-edgeblock strong) { + color: #4ade80; + font-size: 44px; + font-weight: 900; + line-height: 0.95; + letter-spacing: -0.05em; +} + +.root :global(.opportunity-hero-edgeblock em) { + color: rgba(191, 219, 254, 0.84); + font-size: 12px; + font-style: normal; + line-height: 1.5; +} + +.root :global(.opportunity-hero-metrics) { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } -.root :global(.opportunity-card-grid.top-opportunities) { - grid-template-columns: repeat(5, minmax(0, 1fr)); +.root :global(.opportunity-hero-metric) { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; + padding: 12px 13px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(9, 16, 30, 0.6); +} + +.root :global(.opportunity-hero-metric span) { + color: rgba(148, 163, 184, 0.8); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.opportunity-hero-metric strong) { + overflow: hidden; + color: #f8fafc; + font-size: 20px; + font-weight: 860; + line-height: 1; + letter-spacing: -0.03em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.opportunity-hero-book) { + display: grid; + grid-template-columns: repeat(2, minmax(110px, 132px)) minmax(0, 1fr); + gap: 12px; + align-items: stretch; +} + +.root :global(.opportunity-book-side) { + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(6, 11, 23, 0.62); +} + +.root :global(.opportunity-book-side span) { + font-size: 11px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.opportunity-book-side strong) { + font-size: 26px; + font-weight: 880; + line-height: 1; + letter-spacing: -0.03em; +} + +.root :global(.opportunity-book-side.yes) { + border-color: rgba(34, 211, 238, 0.22); +} + +.root :global(.opportunity-book-side.yes span), +.root :global(.opportunity-book-side.yes strong) { + color: #22d3ee; +} + +.root :global(.opportunity-book-side.no) { + border-color: rgba(251, 113, 133, 0.22); +} + +.root :global(.opportunity-book-side.no span), +.root :global(.opportunity-book-side.no strong) { + color: #fda4af; +} + +.root :global(.opportunity-hero-sparkline-wrap) { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: + radial-gradient( + circle at 100% 0%, + rgba(59, 130, 246, 0.14), + transparent 32% + ), + rgba(8, 15, 28, 0.66); +} + +.root :global(.opportunity-hero-sparkline-wrap span) { + color: rgba(148, 163, 184, 0.8); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.opportunity-hero-sparkline) { + width: 100%; + height: 44px; +} + +.root :global(.opportunity-hero-sparkline polyline) { + fill: none; + stroke: #18e6d4; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.26)); +} + +.root :global(.opportunity-hero-footer) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: rgba(191, 219, 254, 0.82); + font-size: 11px; + font-weight: 700; +} + +.root :global(.opportunity-side-grid) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.root :global(.opportunity-side-tile) { + display: flex; + min-height: 116px; + flex-direction: column; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: + radial-gradient( + circle at 100% 0%, + rgba(30, 64, 175, 0.12), + transparent 30% + ), + rgba(9, 16, 30, 0.68); +} + +.root :global(.opportunity-side-tile-head span) { + color: rgba(241, 245, 249, 0.92); + font-size: 13px; + font-weight: 790; +} + +.root :global(.opportunity-side-tile-body) { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.root :global(.opportunity-side-stat) { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.root :global(.opportunity-side-stat b) { + overflow: hidden; + color: #f8fafc; + font-size: 24px; + font-weight: 880; + line-height: 1; + letter-spacing: -0.04em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.opportunity-side-stat span) { + color: rgba(148, 163, 184, 0.82); + font-size: 10px; + font-weight: 680; +} + +.root :global(.opportunity-side-stat b.accent-red) { + color: #fb7185; +} + +.root :global(.opportunity-side-stat b.accent-amber) { + color: #fbbf24; +} + +.root :global(.opportunity-side-stat b.accent-green) { + color: #4ade80; +} + +.root :global(.opportunity-side-stat b.accent-cyan) { + color: #22d3ee; +} + +.root :global(.opportunity-mini-grid) { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; } .root :global(.opportunity-empty-state) { display: flex; - min-height: 132px; + min-height: 168px; align-items: center; justify-content: center; padding: 0 18px; @@ -2263,16 +2625,17 @@ line-height: 1.6; } -.root :global(.opportunity-card) { +.root :global(.opportunity-mini-card) { position: relative; + overflow: hidden; min-width: 0; display: flex; flex-direction: column; - gap: 9px; - min-height: 132px; + gap: 12px; + min-height: 152px; padding: 14px; border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 16px; + border-radius: 18px; background: radial-gradient( circle at 100% 0%, @@ -2290,7 +2653,7 @@ background 160ms ease; } -.root :global(.opportunity-card:hover) { +.root :global(.opportunity-mini-card:hover) { transform: translateY(-2px); border-color: rgba(34, 211, 238, 0.32); background: @@ -2302,32 +2665,34 @@ rgba(15, 23, 42, 0.92); } -.root :global(.opportunity-card-header), -.root :global(.opportunity-card-footer) { +.root :global(.opportunity-mini-head) { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 8px; } -.root :global(.opportunity-price-pair) { - display: inline-flex; - align-items: center; - gap: 10px; -} - -.root :global(.opportunity-rank) { - width: 24px; - height: 24px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.24); +.root :global(.opportunity-mini-rank) { + width: 30px; + height: 30px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.2); display: inline-flex; align-items: center; justify-content: center; color: #f8fafc; - font-size: 12px; - font-weight: 800; + font-size: 13px; + font-weight: 840; flex-shrink: 0; + background: rgba(6, 11, 23, 0.46); +} + +.root :global(.opportunity-mini-copy) { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; } .root :global(.opportunity-pill) { @@ -2355,22 +2720,31 @@ border-color: rgba(74, 222, 128, 0.24); } -.root :global(.opportunity-city) { +.root :global(.opportunity-mini-copy strong) { overflow: hidden; color: #f8fafc; - font-size: 13px; - font-weight: 850; + font-size: 15px; + font-weight: 860; text-overflow: ellipsis; white-space: nowrap; } -.root :global(.opportunity-meta) { +.root :global(.opportunity-mini-copy p) { + display: -webkit-box; overflow: hidden; color: rgba(203, 213, 225, 0.88); font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; - min-height: 16px; + line-height: 1.45; + min-height: 32px; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.root :global(.opportunity-mini-metrics) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; } .root :global(.opportunity-yes) { @@ -2391,13 +2765,13 @@ font-weight: 800; } -.root :global(.opportunity-sparkline) { +.root :global(.opportunity-mini-sparkline) { width: 100%; height: 28px; margin-top: auto; } -.root :global(.opportunity-sparkline polyline) { +.root :global(.opportunity-mini-sparkline polyline) { fill: none; stroke: #18e6d4; stroke-width: 2; @@ -2452,10 +2826,13 @@ :global(html.light) .root :global(.home-weather-stat strong), :global(html.light) .root :global(.home-deb-card strong), :global(html.light) .root :global(.home-card-section h3), -:global(html.light) .root :global(.home-summary-card-head strong), -:global(html.light) .root :global(.opportunity-strip-heading strong), -:global(html.light) .root :global(.opportunity-city), -:global(html.light) .root :global(.home-summary-stat b), +:global(html.light) .root :global(.opportunity-strip-copy strong), +:global(html.light) .root :global(.opportunity-hero-title-row strong), +:global(html.light) .root :global(.opportunity-hero-metric strong), +:global(html.light) .root :global(.opportunity-hero-edgeblock strong), +:global(html.light) .root :global(.opportunity-book-side strong), +:global(html.light) .root :global(.opportunity-side-stat b), +:global(html.light) .root :global(.opportunity-mini-copy strong), :global(html.light) .root :global(.city-item .city-name-text) { color: #0b1726; } @@ -2467,9 +2844,19 @@ :global(html.light) .root :global(.home-weather-stat small), :global(html.light) .root :global(.home-deb-card small), :global(html.light) .root :global(.home-card-section h3 small), -:global(html.light) .root :global(.home-summary-stat span), -:global(html.light) .root :global(.opportunity-strip-heading span), -:global(html.light) .root :global(.opportunity-meta), +:global(html.light) .root :global(.opportunity-strip-kicker), +:global(html.light) .root :global(.opportunity-strip-copy p), +:global(html.light) .root :global(.opportunity-tape-pill span), +:global(html.light) .root :global(.opportunity-hero-kicker), +:global(html.light) .root :global(.opportunity-hero-date), +:global(html.light) .root :global(.opportunity-hero-copy p), +:global(html.light) .root :global(.opportunity-hero-metric span), +:global(html.light) .root :global(.opportunity-hero-edgeblock span), +:global(html.light) .root :global(.opportunity-hero-edgeblock em), +:global(html.light) .root :global(.opportunity-hero-footer), +:global(html.light) .root :global(.opportunity-side-tile-head span), +:global(html.light) .root :global(.opportunity-side-stat span), +:global(html.light) .root :global(.opportunity-mini-copy p), :global(html.light) .root :global(.city-group-title), :global(html.light) .root :global(.home-panel-kicker), :global(html.light) .root :global(.home-panel-airport) { @@ -2514,7 +2901,13 @@ } :global(html.light) .root :global(.home-summary-card), -:global(html.light) .root :global(.opportunity-card), +:global(html.light) .root :global(.opportunity-hero-card), +:global(html.light) .root :global(.opportunity-side-tile), +:global(html.light) .root :global(.opportunity-mini-card), +:global(html.light) .root :global(.opportunity-tape-pill), +:global(html.light) .root :global(.opportunity-hero-metric), +:global(html.light) .root :global(.opportunity-book-side), +:global(html.light) .root :global(.opportunity-hero-sparkline-wrap), :global(html.light) .root :global(.home-deb-card), :global(html.light) .root :global(.home-card-section), :global(html.light) .root :global(.home-metric-card), @@ -2630,6 +3023,33 @@ } } +@media (max-width: 1240px) { + .root :global(.opportunity-strip-topline) { + flex-direction: column; + } + + .root :global(.opportunity-strip-tape) { + max-width: none; + justify-content: flex-start; + } + + .root :global(.opportunity-hero-metrics) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .root :global(.opportunity-hero-book) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .root :global(.opportunity-hero-sparkline-wrap) { + grid-column: 1 / -1; + } + + .root :global(.opportunity-mini-grid) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 1020px) { .root :global(.map) { left: 18px; @@ -2638,10 +3058,13 @@ .root :global(.home-opportunity-strip) { left: 18px; + } + + .root :global(.opportunity-strip-main) { grid-template-columns: 1fr; } - .root :global(.opportunity-card-grid) { + .root :global(.opportunity-side-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -4252,7 +4675,6 @@ 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.05); pointer-events: none; - animation: nearby-fade-in 0.4s ease-out; white-space: nowrap; } @@ -4300,19 +4722,8 @@ border: 1.5px solid var(--accent-cyan); border-radius: 50%; transform: translate(-50%, -50%); - animation: sonar-pulse 2s infinite cubic-bezier(0, 0.2, 0.8, 1); - opacity: 0; -} - -@keyframes sonar-pulse { - 0% { - transform: translate(-50%, -50%) scale(0.5); - opacity: 0.8; - } - 100% { - transform: translate(-50%, -50%) scale(2.5); - opacity: 0; - } + opacity: 0.3; + box-shadow: 0 0 10px rgba(34, 211, 238, 0.18); } .root :global(.nearby-content) { diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index 50ea61a6..fd102f00 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -1618,6 +1618,7 @@ function OpportunityStrip({ const store = useDashboardStore(); const stripState = useMemo(() => { const targetSet = new Set(scanTargetNames); + const totalCount = scanTargetNames.length || snapshots.length; const targetedSnapshots = (scanTargetNames.length ? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name)) : snapshots @@ -1665,6 +1666,9 @@ function OpportunityStrip({ }, 0) / tradableSnapshots.length : null; const bestTradableSnapshot = tradableSnapshots[0] || null; + const bestTradableEdge = bestTradableSnapshot + ? normalizeEdgePercent(getMarketEdgeValue(bestTradableSnapshot.detail)) + : null; const bestTradableCityName = bestTradableSnapshot ? getLocalizedCityDisplay( bestTradableSnapshot.city, @@ -1682,16 +1686,23 @@ function OpportunityStrip({ const lowCount = snapshots.filter( (snapshot) => snapshot.city.deb_recent_tier === "low", ).length; + const noEdgeCount = Math.max(completedCount - tradableSnapshots.length, 0); return { items: tradableSnapshots.slice(0, 5), + totalCount, completedCount, pendingCount, errorCount, liveCount: liveSnapshots.length, tradableCount: tradableSnapshots.length, + noEdgeCount, yesCount, noCount, + avgTradableEdge, + bestTradableSnapshot, + bestTradableEdge, + bestTradableCityName, summaryCards: [ { key: "scan-progress", @@ -1699,7 +1710,7 @@ function OpportunityStrip({ items: [ { label: locale === "en-US" ? "Completed" : "已完成", - value: `${completedCount}/${scanTargetNames.length || snapshots.length}`, + value: `${completedCount}/${totalCount}`, accent: "cyan" as const, }, { @@ -1730,7 +1741,7 @@ function OpportunityStrip({ }, { label: locale === "en-US" ? "No edge" : "无机会", - value: String(Math.max(completedCount - tradableSnapshots.length, 0)), + value: String(noEdgeCount), }, ], }, @@ -1779,6 +1790,8 @@ function OpportunityStrip({ ], }, ], + overviewKicker: + locale === "en-US" ? "EMOS Market Scanner" : "EMOS 交易扫描台", headingTitle: tradableSnapshots.length > 0 ? locale === "en-US" @@ -1786,11 +1799,49 @@ function OpportunityStrip({ : `发现 ${tradableSnapshots.length} 个可交易市场 · 当前最佳 ${bestTradableCityName}` : pendingCount > 0 ? locale === "en-US" - ? `Scanning ${completedCount}/${scanTargetNames.length || snapshots.length} cities` - : `正在扫描 ${completedCount}/${scanTargetNames.length || snapshots.length} 个城市市场层` + ? `Scanning ${completedCount}/${totalCount} cities` + : `正在扫描 ${completedCount}/${totalCount} 个城市市场层` : locale === "en-US" ? `Completed ${completedCount} scans · no tradable market` : `已完成 ${completedCount} 个城市扫描 · 当前无可交易市场`, + overviewBody: + tradableSnapshots.length > 0 + ? locale === "en-US" + ? "Use EMOS distribution against the live CLOB quote and surface the highest executable edge first." + : "用 EMOS 概率分布对齐实时 CLOB 盘口,优先展示当前可执行 edge 最高的市场。" + : pendingCount > 0 + ? locale === "en-US" + ? "The opportunity layer is filling active markets and will promote the first executable setup when quotes arrive." + : "机会层正在补齐活跃盘口,等最新报价返回后会直接抬出第一优先机会。" + : locale === "en-US" + ? "Scanner is live, but the current market prices do not beat the EMOS distribution yet." + : "扫描器在线,但当前盘口价格还没有跑赢 EMOS 概率分布。", + tapeStats: [ + { + key: "coverage", + label: locale === "en-US" ? "Coverage" : "覆盖城市", + value: `${completedCount}/${totalCount}`, + accent: "cyan" as const, + }, + { + key: "live", + label: locale === "en-US" ? "Live" : "在线盘口", + value: String(liveSnapshots.length), + accent: "green" as const, + }, + { + key: "tradable", + label: locale === "en-US" ? "Tradable" : "可交易", + value: String(tradableSnapshots.length), + accent: "cyan" as const, + }, + { + key: "avg-edge", + label: locale === "en-US" ? "Avg edge" : "平均优势", + value: formatEdge(avgTradableEdge), + accent: avgTradableEdge != null ? ("green" as const) : ("slate" as const), + }, + ], yesCountLabel: locale === "en-US" ? `YES bias ${yesCount}` : `YES 倾向 ${yesCount}`, noCountLabel: @@ -1800,84 +1851,293 @@ function OpportunityStrip({ if (!snapshots.length) return null; + const heroSnapshot = stripState.bestTradableSnapshot; + const heroSide = getBestSide(heroSnapshot?.detail); + const heroSignalLabel = + heroSnapshot?.detail?.market_scan?.signal_label || + (heroSide === "yes" + ? locale === "en-US" + ? "BUY YES" + : "买入 YES" + : heroSide === "no" + ? locale === "en-US" + ? "BUY NO" + : "买入 NO" + : locale === "en-US" + ? "MONITOR" + : "继续监控"); + const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes") + ? "yes" + : heroSignalLabel.toLowerCase().includes("no") + ? "no" + : "neutral"; + const heroSymbol = heroSnapshot + ? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail) + : "°C"; + const heroTier = + heroSnapshot?.city.deb_recent_tier || + heroSnapshot?.city.risk_level || + heroSnapshot?.summary?.risk?.level || + heroSnapshot?.detail?.risk?.level; + const heroCityName = heroSnapshot + ? getLocalizedCityDisplay( + heroSnapshot.city, + locale, + heroSnapshot.summary, + heroSnapshot.detail, + ) + : "--"; + const heroMarketBucket = heroSnapshot?.detail?.market_scan?.temperature_bucket; + const heroQuestion = + heroSnapshot?.detail?.market_scan?.primary_market?.question || + `${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${ + heroSnapshot?.detail?.market_scan?.selected_date || + heroSnapshot?.detail?.local_date || + "" + }`; + const heroEngine = (() => { + const engine = String( + heroSnapshot?.detail?.market_scan?.probability_engine || "EMOS", + ).toLowerCase(); + if (engine.includes("emos")) return "EMOS"; + return engine.toUpperCase(); + })(); + const heroModelProbability = + heroSnapshot?.detail?.market_scan?.model_probability ?? + heroMarketBucket?.probability ?? + null; + const heroMidpoint = + heroSnapshot?.detail?.market_scan?.midpoint ?? + heroSnapshot?.detail?.market_scan?.market_price ?? + null; + const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null; + const heroConfidence = + heroSnapshot?.detail?.market_scan?.confidence || null; + const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source + ? String(heroSnapshot.detail.market_scan.quote_source) + .replaceAll("_", " ") + .toUpperCase() + : "CLOB REST"; + const heroDateLabel = + heroSnapshot?.detail?.market_scan?.selected_date || + heroSnapshot?.detail?.local_date || + "--"; + const heroSparkline = buildSparklinePoints( + heroSnapshot?.detail?.market_scan?.sparkline?.length + ? heroSnapshot.detail.market_scan.sparkline + : [ + Number( + heroSnapshot?.summary?.current?.temp ?? + heroSnapshot?.detail?.current?.temp ?? + 0, + ), + Number( + heroSnapshot?.summary?.deb?.prediction ?? + heroSnapshot?.detail?.deb?.prediction ?? + 0, + ), + Number( + heroSnapshot?.detail?.forecast?.today_high ?? + heroSnapshot?.summary?.deb?.prediction ?? + 0, + ), + ], + ); + const secondaryItems = heroSnapshot + ? stripState.items.filter((snapshot) => snapshot.city.name !== heroSnapshot.city.name) + : []; + return (
-
- {stripState.summaryCards.map((card) => ( -
-
- {card.title} +
+
+ + {stripState.overviewKicker} + + {stripState.headingTitle} +

{stripState.overviewBody}

+
+
+ {stripState.tapeStats.map((item) => ( +
+ {item.label} + {item.value}
-
- {card.items.map((item) => ( -
- + {stripState.yesCountLabel} +
+
+ {stripState.noCountLabel} +
+
+
+ {heroSnapshot ? ( + <> +
+ + +
+ {stripState.summaryCards.map((card) => ( +
+
+ {card.title} +
+
+ {card.items.map((item) => ( +
+ + {item.value} + + {item.label} +
+ ))} +
))}
- ))} -
-
-
- - {locale === "en-US" - ? "Live Market Scan" - : "实时市场扫描"} - - {stripState.headingTitle} -
-
- - {locale === "en-US" - ? `Live ${stripState.liveCount}` - : `在线 ${stripState.liveCount}`} - - 0 ? "pending" : "muted", - )} - > - {locale === "en-US" - ? `Pending ${stripState.pendingCount}` - : `待补齐 ${stripState.pendingCount}`} - - 0 ? "tradable" : "muted", - )} - > - {locale === "en-US" - ? `Tradable ${stripState.tradableCount}` - : `可交易 ${stripState.tradableCount}`} - - - {stripState.yesCountLabel} - - - {stripState.noCountLabel} - -
-
- {stripState.items.length > 0 ? ( -
- {stripState.items.map(({ city, detail, summary }, index) => { + {secondaryItems.length > 0 ? ( +
+ {secondaryItems.map(({ city, detail, summary }, index) => { const symbol = getTempSymbol(city, summary, detail); const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction; @@ -1917,14 +2177,17 @@ function OpportunityStrip({ ); })} -
+
+ ) : null} + ) : (
@@ -1973,8 +2239,8 @@ function OpportunityStrip({ {locale === "en-US" - ? `Completed ${stripState.completedCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.` - : `已完成 ${stripState.completedCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`} + ? `Completed ${stripState.completedCount}/${stripState.totalCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.` + : `已完成 ${stripState.completedCount}/${stripState.totalCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`}
diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index 5b1536bb..8fc7b681 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -381,6 +381,56 @@ function getNearbyMarkerDisplayOffset( return presets[index % presets.length]; } +function buildNearbyRenderSignature( + detail: CityDetail, + stations: NearbyStation[], +) { + const uiLang = + typeof document !== "undefined" + ? String(document.documentElement.lang || "").toLowerCase() + : ""; + const cityName = String(detail.name || "").trim().toLowerCase(); + const cityLat = Number(detail.lat); + const cityLon = Number(detail.lon); + const stationSignature = stations + .map((station) => + [ + String( + station.station_code || + station.station_label || + station.name || + station.icao || + "", + ) + .trim() + .toLowerCase(), + Number(station.lat).toFixed(5), + Number(station.lon).toFixed(5), + Number(station.temp ?? Number.NaN).toFixed(2), + String(station.obs_time_epoch || station.obs_time || "").trim(), + String(station.obs_time_label || "").trim(), + String(station.sync_status || "").trim().toLowerCase(), + Number(station.time_delta_vs_anchor_minutes ?? Number.NaN).toFixed(1), + Number(station.wind_dir ?? Number.NaN).toFixed(1), + Number(station.wind_speed ?? station.wind_speed_kt ?? Number.NaN).toFixed( + 1, + ), + String(station.wind_direction_text || "").trim().toLowerCase(), + String(station.wind_power_text || "").trim().toLowerCase(), + ].join("|"), + ) + .join("~"); + + return [ + uiLang, + detail.temp_symbol || "", + cityName, + Number.isFinite(cityLat) ? cityLat.toFixed(5) : "", + Number.isFinite(cityLon) ? cityLon.toFixed(5) : "", + stationSignature, + ].join("::"); +} + export function useLeafletMap({ cities, cityDetailsByName, @@ -422,6 +472,7 @@ export function useLeafletMap({ null, ); const lastNearbyRefreshAtRef = useRef>({}); + const lastNearbyRenderSignatureRef = useRef(null); useEffect(() => { onClosePanelRef.current = onClosePanel; @@ -675,11 +726,29 @@ export function useLeafletMap({ nearbyRefreshTimerRef.current = null; } }; + const clearRenderedNearby = () => { + layer.clearLayers(); + lastNearbyRenderSignatureRef.current = null; + }; function renderNearbyStations(detail: CityDetail, preserveView = false) { - layer.clearLayers(); + const nearbyStations = pickMapNearbyStations(detail).filter((station) => { + const sLat = Number(station.lat); + const sLon = Number(station.lon); + if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return false; + if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return false; + return true; + }); + const renderSignature = buildNearbyRenderSignature(detail, nearbyStations); - const nearbyStations = pickMapNearbyStations(detail); + if ( + renderSignature === lastNearbyRenderSignatureRef.current && + layer.getLayers().length === nearbyStations.length + ) { + return; + } + + clearRenderedNearby(); if (!nearbyStations.length) { if (!preserveView && detail.lat != null && detail.lon != null) { @@ -692,6 +761,8 @@ export function useLeafletMap({ return; } + lastNearbyRenderSignatureRef.current = renderSignature; + const latLngs: Array<[number, number]> = []; if (detail.lat != null && detail.lon != null) { latLngs.push([detail.lat, detail.lon]); @@ -700,10 +771,6 @@ export function useLeafletMap({ nearbyStations.forEach((station) => { const sLat = Number(station.lat); const sLon = Number(station.lon); - // Ignore invalid (0,0) or null coordinates which cause global zoom-out - if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return; - if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return; - const displayOffset = getNearbyMarkerDisplayOffset(detail, station, latLngs.length); const styleAttr = displayOffset.x || displayOffset.y @@ -772,7 +839,7 @@ export function useLeafletMap({ if (selectedCity && isLoadingDetail) { autoNearbyCityRef.current = selectedCity; clearNearbyRefreshTimer(); - layer.clearLayers(); + clearRenderedNearby(); return; } @@ -803,7 +870,7 @@ export function useLeafletMap({ if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) { autoNearbyCityRef.current = null; clearNearbyRefreshTimer(); - layer.clearLayers(); + clearRenderedNearby(); return; } @@ -824,7 +891,7 @@ export function useLeafletMap({ if (!targetCity) { autoNearbyCityRef.current = null; clearNearbyRefreshTimer(); - layer.clearLayers(); + clearRenderedNearby(); return; } diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 5a6c0f4c..56af297b 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -279,6 +279,7 @@ export interface DailyModelForecast { prediction?: number | null; }; probabilities?: ProbabilityBucket[]; + probabilities_all?: ProbabilityBucket[]; } export interface MarketToken { diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index e2a37641..0977511f 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -429,6 +429,8 @@ class PolymarketReadOnlyLayer: target_date: Any, temperature_bucket: Optional[Dict[str, Any]] = None, model_probability: Optional[float] = None, + probability_distribution: Optional[List[Dict[str, Any]]] = None, + temp_symbol: Optional[str] = None, fallback_sparkline: Optional[List[float]] = None, forced_market_slug: Optional[str] = None, include_related_buckets: bool = True, @@ -629,6 +631,13 @@ class PolymarketReadOnlyLayer: or _extract_price(yes_prices.get("buy")) or _extract_price(yes_token.get("implied_probability")) ) + distribution_model_probability = self._aggregate_distribution_probability_for_market( + market=market, + probability_distribution=probability_distribution, + temp_symbol=temp_symbol, + ) + if distribution_model_probability is not None: + model_probability = distribution_model_probability edge_percent = None if model_probability is not None and market_price is not None: @@ -651,6 +660,8 @@ class PolymarketReadOnlyLayer: city_key=market_city_key, target_date=date_str, primary_market=market, + probability_distribution=probability_distribution, + temp_symbol=temp_symbol, limit=all_bucket_limit, ) top_buckets = list(all_buckets[:top_bucket_limit]) @@ -724,6 +735,7 @@ class PolymarketReadOnlyLayer: "primary_market": primary_market_payload, "selected_condition_id": condition_id, "selected_slug": market_slug, + "model_probability": model_probability, "market_price": market_price, "midpoint": yes_midpoint if yes_midpoint is not None else market_price, "spread": yes_spread, @@ -1388,6 +1400,91 @@ class PolymarketReadOnlyLayer: month_name = dt.strftime("%B").lower() return f"highest-temperature-in-{city_slug}-on-{month_name}-{dt.day}-{dt.year}" + def _is_fahrenheit_symbol(self, symbol: Optional[str]) -> bool: + return "F" in str(symbol or "").upper() + + def _convert_temp_to_market_unit( + self, + value: Optional[float], + source_symbol: Optional[str], + market_unit: Optional[str], + ) -> Optional[float]: + numeric = _safe_float(value) + if numeric is None: + return None + normalized_unit = str(market_unit or "").upper() + source_is_f = self._is_fahrenheit_symbol(source_symbol) + if normalized_unit == "F": + return numeric if source_is_f else (numeric * 9.0 / 5.0) + 32.0 + return ((numeric - 32.0) * 5.0 / 9.0) if source_is_f else numeric + + def _market_bucket_contains_distribution_temp( + self, + market: Dict[str, Any], + distribution_temp: Optional[float], + temp_symbol: Optional[str], + ) -> bool: + compare_temp = self._convert_temp_to_market_unit( + distribution_temp, + source_symbol=temp_symbol, + market_unit=(self._extract_market_bucket_range(market) or (None, None, "C"))[2], + ) + if compare_temp is None: + return False + + bucket_range = self._extract_market_bucket_range(market) + lower = bucket_range[0] if bucket_range else None + upper = bucket_range[1] if bucket_range else None + unit = bucket_range[2] if bucket_range else "C" + direction = self._extract_market_bucket_direction(market) + + if lower is not None and upper is not None: + return compare_temp >= lower - 0.01 and compare_temp <= upper + 0.01 + if lower is not None and direction == "above": + return compare_temp >= lower - 0.01 + if lower is not None and direction == "below": + return compare_temp <= lower + 0.01 + + reference = self._extract_market_bucket_temp(market) + if reference is None: + return False + tolerance = 0.56 if str(unit or "").upper() == "F" else 0.26 + return abs(compare_temp - reference) <= tolerance + + def _aggregate_distribution_probability_for_market( + self, + market: Dict[str, Any], + probability_distribution: Optional[List[Dict[str, Any]]], + temp_symbol: Optional[str], + ) -> Optional[float]: + if not isinstance(probability_distribution, list) or not probability_distribution: + return None + + total = 0.0 + matched = 0 + for row in probability_distribution: + if not isinstance(row, dict): + continue + distribution_temp = _safe_float(row.get("value")) + if distribution_temp is None: + continue + if not self._market_bucket_contains_distribution_temp( + market, + distribution_temp, + temp_symbol, + ): + continue + raw_probability = _safe_float(row.get("probability")) + if raw_probability is None: + continue + probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability + probability = max(0.0, min(1.0, probability)) + total += probability + matched += 1 + if matched <= 0: + return None + return max(0.0, min(1.0, total)) + def _load_markets(self, active_only: bool = True) -> List[Dict[str, Any]]: now = time.time() with self._lock: @@ -1644,6 +1741,8 @@ class PolymarketReadOnlyLayer: city_key: str, target_date: str, primary_market: Dict[str, Any], + probability_distribution: Optional[List[Dict[str, Any]]] = None, + temp_symbol: Optional[str] = None, limit: int = 4, ) -> List[Dict[str, Any]]: candidate_markets = self._collect_related_temperature_markets( @@ -1659,6 +1758,7 @@ class PolymarketReadOnlyLayer: float, float, float, + float, Dict[str, Any], Dict[str, Any], Dict[str, Any], @@ -1701,6 +1801,11 @@ class PolymarketReadOnlyLayer: continue market_prob = max(0.0, min(1.0, float(market_prob))) + model_prob = self._aggregate_distribution_probability_for_market( + market=market, + probability_distribution=probability_distribution, + temp_symbol=temp_symbol, + ) volume = ( _extract_price( market.get("volumeNum") @@ -1711,9 +1816,10 @@ class PolymarketReadOnlyLayer: ) ranked.append( ( - market_prob, + model_prob if model_prob is not None else market_prob, volume, bucket_temp, + market_prob, market, yes_token, no_token, @@ -1735,9 +1841,10 @@ class PolymarketReadOnlyLayer: def _append_rows(enforce_primary_direction: bool) -> None: for ( - market_prob, + model_prob, _volume, bucket_temp, + market_prob, market, yes_token, no_token, @@ -1779,8 +1886,14 @@ class PolymarketReadOnlyLayer: "lower": bucket_range[0] if bucket_range else None, "upper": bucket_range[1] if bucket_range else None, "unit": bucket_range[2] if bucket_range else None, - "probability": market_prob, + "probability": model_prob, + "model_probability": model_prob, "market_price": yes_midpoint, + "edge_percent": ( + (model_prob - yes_midpoint) * 100.0 + if model_prob is not None and yes_midpoint is not None + else None + ), "yes_buy": yes_buy, "yes_sell": yes_sell, "no_buy": no_buy, diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index c9c6b445..c34d41d0 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -232,6 +232,97 @@ def test_build_market_scan_lite_skips_related_buckets(): assert called["bucket"] == 0 +def test_build_market_scan_aggregates_emos_probability_for_threshold_market(): + layer = PolymarketReadOnlyLayer() + + layer._find_primary_market = lambda *_args, **_kwargs: ( + { + "id": "market-1", + "question": "Will the highest temperature in Shenzhen be 30C or higher on April 23?", + "slug": "highest-temperature-in-shenzhen-on-april-23-2026-30c-or-higher", + "conditionId": "condition-1", + "active": True, + "closed": False, + "acceptingOrders": True, + }, + None, + ) + layer._extract_market_tokens = lambda _market: [ + {"outcome": "Yes", "token_id": "yes-token"}, + {"outcome": "No", "token_id": "no-token"}, + ] + layer._get_token_market_data = lambda token_id: ( + {"buy": 0.42, "sell": 0.40, "midpoint": 0.41} + if token_id == "yes-token" + else {"buy": 0.61, "sell": 0.59, "midpoint": 0.60} + ) + layer._build_top_temperature_buckets = lambda **_kwargs: [] + + scan = layer.build_market_scan( + city="Shenzhen", + target_date="2026-04-23", + temperature_bucket={"temp": 30, "probability": 0.30}, + model_probability=0.30, + probability_distribution=[ + {"value": 29, "probability": 0.20}, + {"value": 30, "probability": 0.30}, + {"value": 31, "probability": 0.50}, + ], + temp_symbol="°C", + ) + + assert round(scan["model_probability"], 6) == 0.8 + assert round(scan["edge_percent"], 6) == 39.0 + + +def test_build_top_temperature_buckets_use_aggregated_emos_probability(): + layer = PolymarketReadOnlyLayer() + + primary_market = { + "slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher", + "question": "Will the highest temperature in Ankara be 14C or higher on March 12?", + "volumeNum": 1000, + } + markets = [ + primary_market, + { + "slug": "highest-temperature-in-ankara-on-march-12-2026-15c-or-higher", + "question": "Will the highest temperature in Ankara be 15C or higher on March 12?", + "volumeNum": 900, + }, + ] + layer._collect_related_temperature_markets = ( + lambda city_key, target_date, primary_market: markets + ) + layer._extract_market_tokens = lambda market: [ + {"outcome": "Yes", "token_id": f"{market['slug']}|yes"}, + {"outcome": "No", "token_id": f"{market['slug']}|no"}, + ] + layer._get_token_market_data = lambda token_id: ( + {"midpoint": 0.41, "buy": 0.42, "sell": 0.40} + if token_id.endswith("|yes") + else {"midpoint": 0.59, "buy": 0.60, "sell": 0.58} + ) + + rows = layer._build_top_temperature_buckets( + city_key="ankara", + target_date="2026-03-12", + primary_market=primary_market, + probability_distribution=[ + {"value": 13, "probability": 0.10}, + {"value": 14, "probability": 0.25}, + {"value": 15, "probability": 0.35}, + {"value": 16, "probability": 0.30}, + ], + temp_symbol="°C", + limit=4, + ) + + assert round(rows[0]["probability"], 6) == 0.9 + assert round(rows[0]["edge_percent"], 6) == 49.0 + assert round(rows[1]["probability"], 6) == 0.65 + + def test_hydrate_bucket_prices_uses_executable_quotes_without_midpoint(): layer = PolymarketReadOnlyLayer() buckets = [ diff --git a/web/analysis_service.py b/web/analysis_service.py index 7e81e19e..b1d02028 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2267,6 +2267,7 @@ def _analyze( d_val, d_winfo = None, "" d_probs = [] + d_probs_all = [] if day_m: try: blended, winfo = calculate_dynamic_weights(city, day_m) @@ -2285,6 +2286,7 @@ def _analyze( prob_obj = calculate_prob_distribution(d_val, d_sigma, None, sym) d_probs = prob_obj.get("probabilities", []) + d_probs_all = prob_obj.get("probabilities_all", d_probs) except Exception: pass @@ -2292,7 +2294,8 @@ def _analyze( multi_model_daily[d_str] = { "models": day_m, "deb": {"prediction": d_val, "weights_info": d_winfo}, - "probabilities": d_probs if i > 0 else probabilities # Use today's real prob for today + "probabilities": d_probs if i > 0 else probabilities, # Use today's real prob for today + "probabilities_all": d_probs_all if i > 0 else probabilities_all, } # ── Assemble result ── @@ -2821,6 +2824,11 @@ def _build_city_market_scan_payload( distribution = selected_daily.get("probabilities") if not isinstance(distribution, list) or not distribution: distribution = data.get("probabilities", {}).get("distribution", []) or [] + distribution_all = selected_daily.get("probabilities_all") + if not isinstance(distribution_all, list) or not distribution_all: + distribution_all = data.get("probabilities", {}).get("distribution_all", []) or [] + if not distribution_all: + distribution_all = distribution model_map = selected_daily.get("models") or data.get("multi_model") or {} if not isinstance(model_map, dict): @@ -2844,24 +2852,18 @@ def _build_city_market_scan_payload( primary_bucket = None if isinstance(distribution, list) and distribution: - if anchor_temp is None: - primary_bucket = distribution[0] + ranked_buckets = [] + for idx, row in enumerate(distribution_all): + if not isinstance(row, dict): + continue + bucket_prob = _sf(row.get("probability")) + prob_rank = bucket_prob if bucket_prob is not None else -1.0 + ranked_buckets.append((-prob_rank, idx, row)) + if ranked_buckets: + ranked_buckets.sort(key=lambda x: (x[0], x[1])) + primary_bucket = ranked_buckets[0][2] else: - ranked_buckets = [] - for idx, row in enumerate(distribution): - if not isinstance(row, dict): - continue - bucket_temp = _sf(row.get("value")) - bucket_prob = _sf(row.get("probability")) - if bucket_temp is None: - continue - prob_rank = bucket_prob if bucket_prob is not None else -1.0 - ranked_buckets.append((abs(bucket_temp - anchor_temp), -prob_rank, idx, row)) - if ranked_buckets: - ranked_buckets.sort(key=lambda x: (x[0], x[1], x[2])) - primary_bucket = ranked_buckets[0][3] - else: - primary_bucket = distribution[0] + primary_bucket = distribution[0] model_probability = None if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None: @@ -2873,7 +2875,7 @@ def _build_city_market_scan_payload( fallback_sparkline = [ p.get("probability", 0) - for p in distribution[:8] + for p in distribution_all[:8] if isinstance(p, dict) ] market_scan = _market_layer.build_market_scan( @@ -2881,6 +2883,8 @@ def _build_city_market_scan_payload( target_date=selected_date or data.get("local_date"), temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None, model_probability=model_probability, + probability_distribution=distribution_all, + temp_symbol=temp_symbol, fallback_sparkline=fallback_sparkline, forced_market_slug=market_slug, include_related_buckets=not lite,