Implement EMOS scan terminal with REST-only market data
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
applyAuthResponseCookies,
|
||||||
|
buildBackendRequestHeaders,
|
||||||
|
} from "@/lib/backend-auth";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of [
|
||||||
|
"scan_mode",
|
||||||
|
"min_price",
|
||||||
|
"max_price",
|
||||||
|
"min_edge_pct",
|
||||||
|
"min_liquidity",
|
||||||
|
"high_liquidity_only",
|
||||||
|
"market_type",
|
||||||
|
"time_range",
|
||||||
|
"limit",
|
||||||
|
"force_refresh",
|
||||||
|
]) {
|
||||||
|
const value = req.nextUrl.searchParams.get(key);
|
||||||
|
if (value != null && value !== "") {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: auth.headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const raw = await res.text();
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const response = NextResponse.json(data, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch scan terminal data", detail: String(error) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
||||||
|
|
||||||
const PolyWeatherDashboard = dynamic(
|
const ScanTerminalDashboard = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("@/components/dashboard/PolyWeatherDashboard").then(
|
import("@/components/dashboard/ScanTerminalDashboard").then(
|
||||||
(module) => module.PolyWeatherDashboard,
|
(module) => module.ScanTerminalDashboard,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -15,5 +15,5 @@ const PolyWeatherDashboard = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function DashboardEntry() {
|
export function DashboardEntry() {
|
||||||
return <PolyWeatherDashboard />;
|
return <ScanTerminalDashboard />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,160 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import { Star } from "lucide-react";
|
import { Star } from "lucide-react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import type {
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
CityDetail,
|
|
||||||
CityListItem,
|
|
||||||
CitySummary,
|
|
||||||
} from "@/lib/dashboard-types";
|
|
||||||
import { getLocalizedCityDisplay } from "@/lib/dashboard-home-copy";
|
|
||||||
|
|
||||||
interface OpportunityRow {
|
function getStatusMeta(
|
||||||
rank: number;
|
row: ScanOpportunityRow,
|
||||||
city: CityListItem;
|
locale: string,
|
||||||
summary?: CitySummary | null;
|
): { label: string; tone: "green" | "amber" | "purple" | "neutral" } {
|
||||||
detail?: CityDetail | null;
|
const phase = String(row.window_phase || "").toLowerCase();
|
||||||
score: number;
|
if (phase === "active_peak" || phase === "setup_today") {
|
||||||
tradable: boolean;
|
return { label: locale === "en-US" ? "Tradable" : "可交易", tone: "green" };
|
||||||
}
|
|
||||||
|
|
||||||
function getTodayHighLabel(detail?: CityDetail | null, symbol = "°C"): string {
|
|
||||||
const current = detail?.current;
|
|
||||||
if (current?.temp != null) return `${current.temp.toFixed(1)}${symbol}`;
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimeLabel(detail?: CityDetail | null): string {
|
|
||||||
if (!detail?.local_time) return "--";
|
|
||||||
return detail.local_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimezone(detail?: CityDetail | null): string {
|
|
||||||
if (detail?.utc_offset_seconds != null) {
|
|
||||||
const hours = Math.round(detail.utc_offset_seconds / 3600);
|
|
||||||
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
|
||||||
}
|
}
|
||||||
return "";
|
if (phase === "tomorrow" || phase === "week_ahead") {
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketVolume(detail?: CityDetail | null): string {
|
|
||||||
const vol =
|
|
||||||
detail?.market_scan?.volume ?? detail?.market_scan?.primary_market?.volume;
|
|
||||||
if (vol == null) return "--";
|
|
||||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (vol >= 1_000) return `$${Math.round(vol / 1_000)}K`;
|
|
||||||
return `$${vol.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScanStatus(
|
|
||||||
detail?: CityDetail | null,
|
|
||||||
locale = "zh-CN",
|
|
||||||
): { label: string; tone: string } {
|
|
||||||
const scan = detail?.market_scan;
|
|
||||||
if (!scan?.available)
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "No Market" : "无盘口",
|
|
||||||
tone: "neutral",
|
|
||||||
};
|
|
||||||
const pa = scan?.price_analysis;
|
|
||||||
const bestSide = pa?.best_side;
|
|
||||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
|
||||||
const edgeVal =
|
|
||||||
sideView?.edge != null
|
|
||||||
? Math.abs(Number(sideView.edge)) > 1
|
|
||||||
? Number(sideView.edge) / 100
|
|
||||||
: Number(sideView.edge)
|
|
||||||
: null;
|
|
||||||
if (edgeVal != null && edgeVal >= 0.05)
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
|
||||||
tone: "amber",
|
|
||||||
};
|
|
||||||
if (edgeVal != null && edgeVal >= 0.02)
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Tradable" : "即时确认",
|
|
||||||
tone: "green",
|
|
||||||
};
|
|
||||||
if (edgeVal != null && edgeVal > 0)
|
|
||||||
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
|
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
|
||||||
return { label: locale === "en-US" ? "Market" : "市场", tone: "neutral" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBestAction(detail?: CityDetail | null, locale = "zh-CN"): string {
|
|
||||||
const scan = detail?.market_scan;
|
|
||||||
if (!scan?.available) return "--";
|
|
||||||
const pa = scan?.price_analysis;
|
|
||||||
const side = pa?.best_side;
|
|
||||||
const topBucket = scan?.top_buckets?.[0];
|
|
||||||
const label = topBucket?.label || topBucket?.slug || "";
|
|
||||||
if (side === "yes" && label)
|
|
||||||
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${label}`;
|
|
||||||
if (side === "no" && label)
|
|
||||||
return `${locale === "en-US" ? "Buy" : "买入"} No ${label}`;
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEdge(detail?: CityDetail | null): number | null {
|
|
||||||
const pa = detail?.market_scan?.price_analysis;
|
|
||||||
const bestSide = pa?.best_side;
|
|
||||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
|
||||||
const edge = sideView?.edge;
|
|
||||||
if (edge == null) return null;
|
|
||||||
const val = Number(edge);
|
|
||||||
return Math.abs(val) > 1 ? val / 100 : val;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProbabilityBuckets(
|
|
||||||
detail?: CityDetail | null,
|
|
||||||
): Array<{ label: string; probability: number }> {
|
|
||||||
const view = detail?.probabilities?.distribution || [];
|
|
||||||
if (!Array.isArray(view)) return [];
|
|
||||||
return view.slice(0, 8).map((b) => ({
|
|
||||||
label: String(b.label || b.value || ""),
|
|
||||||
probability: Number(b.probability || 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniProbabilityChart({
|
|
||||||
buckets,
|
|
||||||
}: {
|
|
||||||
buckets: Array<{ label: string; probability: number }>;
|
|
||||||
}) {
|
|
||||||
if (!buckets.length) return <span className="scan-no-data">--</span>;
|
|
||||||
const maxProb = Math.max(...buckets.map((b) => b.probability), 0.01);
|
|
||||||
return (
|
|
||||||
<div className="scan-mini-chart">
|
|
||||||
{buckets.map((bucket, i) => (
|
|
||||||
<div key={i} className="scan-mini-bar-col">
|
|
||||||
<div
|
|
||||||
className="scan-mini-bar"
|
|
||||||
style={{
|
|
||||||
height: `${Math.max((bucket.probability / maxProb) * 100, 4)}%`,
|
|
||||||
}}
|
|
||||||
title={`${bucket.label}: ${(bucket.probability * 100).toFixed(1)}%`}
|
|
||||||
/>
|
|
||||||
<span className="scan-mini-bar-label">
|
|
||||||
{bucket.label.replace(/[°CF]/g, "")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayScore(rawScore: number): number {
|
|
||||||
if (rawScore <= 0) return Math.max(0, 70 + rawScore / 1000);
|
|
||||||
if (rawScore > 1000) {
|
|
||||||
return Math.min(100, 80 + (rawScore - 1000) / 30);
|
|
||||||
}
|
}
|
||||||
return Math.min(80, Math.max(0, (rawScore / 1000) * 80));
|
if (phase === "post_peak") {
|
||||||
|
return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber" };
|
||||||
|
}
|
||||||
|
return { label: locale === "en-US" ? "Watching" : "观察中", tone: "neutral" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreRing({ score }: { score: number }) {
|
function formatPercent(value?: number | null, signed = false) {
|
||||||
const displayScore = getDisplayScore(score);
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!signed) return `${numeric.toFixed(1)}%`;
|
||||||
|
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeBlock(row: ScanOpportunityRow, locale: string) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (row.local_time) {
|
||||||
|
parts.push(row.local_time);
|
||||||
|
}
|
||||||
|
if (row.selected_date) {
|
||||||
|
parts.push(row.selected_date);
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction(row: ScanOpportunityRow, locale: string) {
|
||||||
|
if (row.side === "yes") {
|
||||||
|
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${row.target_label || ""}`.trim();
|
||||||
|
}
|
||||||
|
if (row.side === "no") {
|
||||||
|
return `${locale === "en-US" ? "Buy" : "买入"} No ${row.target_label || ""}`.trim();
|
||||||
|
}
|
||||||
|
return row.action || "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProbability(value?: number | null) {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
|
return `${(Number(value) * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreRing({ score }: { score?: number | null }) {
|
||||||
|
const displayScore = Math.max(0, Math.min(100, Number(score || 0)));
|
||||||
const radius = 20;
|
const radius = 20;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
const progress = (displayScore / 100) * circumference;
|
const progress = (displayScore / 100) * circumference;
|
||||||
@@ -191,27 +94,33 @@ function ScoreRing({ score }: { score: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
|
export function OpportunityTable({
|
||||||
|
rows,
|
||||||
|
selectedRowId,
|
||||||
|
onSelectRow,
|
||||||
|
}: {
|
||||||
|
rows: ScanOpportunityRow[];
|
||||||
|
selectedRowId?: string | null;
|
||||||
|
onSelectRow?: (row: ScanOpportunityRow) => void;
|
||||||
|
}) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const store = useDashboardStore();
|
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scan-table-container">
|
<div className="scan-table-container">
|
||||||
{/* Header */}
|
|
||||||
<div className="scan-table-header">
|
<div className="scan-table-header">
|
||||||
<span className="scan-th scan-th-rank">#</span>
|
<span className="scan-th scan-th-rank">#</span>
|
||||||
<span className="scan-th scan-th-city">
|
<span className="scan-th scan-th-city">
|
||||||
{isEn ? "City / Market" : "城市 / 市场"}
|
{isEn ? "City / Market" : "城市 / 市场"}
|
||||||
</span>
|
</span>
|
||||||
<span className="scan-th scan-th-time">
|
<span className="scan-th scan-th-time">
|
||||||
{isEn ? "Local Time / Status" : "当前时间 / 阶段"}
|
{isEn ? "Time / Phase" : "时间 / 阶段"}
|
||||||
</span>
|
</span>
|
||||||
<span className="scan-th scan-th-prob">
|
<span className="scan-th scan-th-prob">
|
||||||
{isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"}
|
{isEn ? "EMOS vs Market" : "EMOS vs 市场"}
|
||||||
</span>
|
</span>
|
||||||
<span className="scan-th scan-th-action">
|
<span className="scan-th scan-th-action">
|
||||||
{isEn ? "Best Action" : "最佳机会"}
|
{isEn ? "Best Action" : "最佳动作"}
|
||||||
</span>
|
</span>
|
||||||
<span className="scan-th scan-th-edge">
|
<span className="scan-th scan-th-edge">
|
||||||
{isEn ? "Edge" : "边际优势"}
|
{isEn ? "Edge" : "边际优势"}
|
||||||
@@ -222,90 +131,99 @@ export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
|
|||||||
<span className="scan-th scan-th-fav" />
|
<span className="scan-th scan-th-fav" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{rows.map((row, index) => {
|
||||||
{rows.map((row) => {
|
const status = getStatusMeta(row, locale);
|
||||||
const cityName = getLocalizedCityDisplay(
|
const localizedCityName = getLocalizedCityName(
|
||||||
row.city,
|
row.city,
|
||||||
|
row.city_display_name || row.display_name || row.city,
|
||||||
locale,
|
locale,
|
||||||
row.summary,
|
|
||||||
row.detail,
|
|
||||||
);
|
);
|
||||||
const status = getScanStatus(row.detail, locale);
|
const selected = selectedRowId === row.id;
|
||||||
const edge = getEdge(row.detail);
|
const finalScore = Number(row.final_score || 0);
|
||||||
const buckets = getProbabilityBuckets(row.detail);
|
|
||||||
const isSelected = store.selectedCity === row.city.name;
|
|
||||||
const symbol = row.detail?.temp_symbol || "°C";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={row.city.name}
|
key={row.id}
|
||||||
className={`scan-table-row ${isSelected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
type="button"
|
||||||
onClick={() => store.focusCity(row.city.name)}
|
className={`scan-table-row ${selected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
||||||
|
onClick={() => onSelectRow?.(row)}
|
||||||
>
|
>
|
||||||
{/* Rank */}
|
|
||||||
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
|
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
|
||||||
<span className="scan-rank-circle">{row.rank}</span>
|
<span className="scan-rank-circle">{row.rank || index + 1}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* City */}
|
|
||||||
<span className="scan-td scan-td-city">
|
<span className="scan-td scan-td-city">
|
||||||
<span className="scan-city-thumb">
|
<span className="scan-city-thumb">
|
||||||
<span className="scan-city-img-placeholder" />
|
<span className="scan-city-img-placeholder" />
|
||||||
</span>
|
</span>
|
||||||
<span className="scan-city-info">
|
<span className="scan-city-info">
|
||||||
<span className="scan-city-name">{cityName}</span>
|
<span className="scan-city-name">{localizedCityName}</span>
|
||||||
<span className="scan-city-sub">
|
<span className="scan-city-sub">
|
||||||
{isEn ? "Today's high" : "今日最高温"} ·{" "}
|
{row.target_label || row.market_question || "--"}
|
||||||
{getMarketVolume(row.detail)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Time + Status */}
|
|
||||||
<span className="scan-td scan-td-time">
|
<span className="scan-td scan-td-time">
|
||||||
<span className="scan-time-text">
|
<span className="scan-time-text">{formatTimeBlock(row, locale)}</span>
|
||||||
{getTimeLabel(row.detail)} ({getTimezone(row.detail)})
|
|
||||||
</span>
|
|
||||||
<span className={`scan-status-badge tone-${status.tone}`}>
|
<span className={`scan-status-badge tone-${status.tone}`}>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Probability Chart */}
|
|
||||||
<span className="scan-td scan-td-prob">
|
<span className="scan-td scan-td-prob">
|
||||||
<MiniProbabilityChart buckets={buckets} />
|
<div className="scan-city-info">
|
||||||
|
<span className="scan-city-name">
|
||||||
|
{formatProbability(row.model_event_probability)} /{" "}
|
||||||
|
{formatProbability(row.market_event_probability)}
|
||||||
|
</span>
|
||||||
|
<span className="scan-city-sub">
|
||||||
|
{isEn ? "Bias" : "偏移"}{" "}
|
||||||
|
{row.distribution_bias_direction || "--"} ·{" "}
|
||||||
|
{formatPercent(row.distribution_bias_score)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Best Action */}
|
|
||||||
<span className="scan-td scan-td-action">
|
<span className="scan-td scan-td-action">
|
||||||
<span className="scan-action-text">
|
<span className="scan-action-text">
|
||||||
{getBestAction(row.detail, locale)}
|
{formatAction(row, locale)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Edge */}
|
|
||||||
<span className="scan-td scan-td-edge">
|
<span className="scan-td scan-td-edge">
|
||||||
<span
|
<span
|
||||||
className={`scan-edge-value ${edge != null && edge > 0 ? "positive" : "neutral"}`}
|
className={`scan-edge-value ${finalScore > 0 ? "positive" : "neutral"}`}
|
||||||
>
|
>
|
||||||
{edge != null
|
{formatPercent(row.edge_percent, true)}
|
||||||
? `${edge > 0 ? "+" : ""}${(edge * 100).toFixed(1)}%`
|
|
||||||
: "--"}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Score */}
|
|
||||||
<span className="scan-td scan-td-score">
|
<span className="scan-td scan-td-score">
|
||||||
<ScoreRing score={row.score} />
|
<ScoreRing score={row.final_score} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Favorite */}
|
|
||||||
<span className="scan-td scan-td-fav">
|
<span className="scan-td scan-td-fav">
|
||||||
<Star size={16} className="scan-fav-icon" />
|
<Star size={16} className="scan-fav-icon" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{!rows.length ? (
|
||||||
|
<div className="scan-detail-empty">
|
||||||
|
<div>
|
||||||
|
<div className="scan-detail-section-title">
|
||||||
|
{isEn ? "No primary signal" : "当前无主信号"}
|
||||||
|
</div>
|
||||||
|
<p className="scan-city-sub">
|
||||||
|
{isEn
|
||||||
|
? "No opportunity passed the price, spread, liquidity, and edge filters."
|
||||||
|
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -10,16 +10,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import styles from "./Dashboard.module.css";
|
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
interface FilterState {
|
export interface FilterState extends ScanTerminalFilters {}
|
||||||
mode: "tradable" | "early" | "touch" | "trend";
|
|
||||||
priceRange: [number, number];
|
|
||||||
minEdge: number;
|
|
||||||
highLiquidityOnly: boolean;
|
|
||||||
marketType: "maxtemp" | "all";
|
|
||||||
timeRange: "today" | "tomorrow" | "week";
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCAN_MODES = [
|
const SCAN_MODES = [
|
||||||
{
|
{
|
||||||
@@ -57,29 +50,27 @@ const SCAN_MODES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ScanFilterPanel({
|
export function ScanFilterPanel({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
onScan,
|
onScan,
|
||||||
isScanning,
|
isScanning,
|
||||||
}: {
|
}: {
|
||||||
|
value: FilterState;
|
||||||
|
onChange?: (filters: FilterState) => void;
|
||||||
onScan?: (filters: FilterState) => void;
|
onScan?: (filters: FilterState) => void;
|
||||||
isScanning?: boolean;
|
isScanning?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
|
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
|
||||||
mode: "tradable",
|
|
||||||
priceRange: [0.05, 0.95],
|
|
||||||
minEdge: 2,
|
|
||||||
highLiquidityOnly: false,
|
|
||||||
marketType: "maxtemp",
|
|
||||||
timeRange: "today",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateFilter = <K extends keyof FilterState>(
|
const updateFilter = <K extends keyof FilterState>(
|
||||||
key: K,
|
key: K,
|
||||||
value: FilterState[K],
|
nextValue: FilterState[K],
|
||||||
) => {
|
) => {
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
[key]: nextValue,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,12 +83,12 @@ export function ScanFilterPanel({
|
|||||||
<div className="scan-mode-tabs">
|
<div className="scan-mode-tabs">
|
||||||
{SCAN_MODES.map((mode) => {
|
{SCAN_MODES.map((mode) => {
|
||||||
const Icon = mode.icon;
|
const Icon = mode.icon;
|
||||||
const isActive = filters.mode === mode.key;
|
const isActive = value.scan_mode === mode.key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||||
onClick={() => updateFilter("mode", mode.key)}
|
onClick={() => updateFilter("scan_mode", mode.key)}
|
||||||
title={isEn ? mode.descEn : mode.descZh}
|
title={isEn ? mode.descEn : mode.descZh}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
@@ -124,17 +115,17 @@ export function ScanFilterPanel({
|
|||||||
{isEn ? "Price Range" : "价格范围"}
|
{isEn ? "Price Range" : "价格范围"}
|
||||||
</span>
|
</span>
|
||||||
<div className="scan-range-display">
|
<div className="scan-range-display">
|
||||||
<span>{filters.priceRange[0].toFixed(2)}</span>
|
<span>{value.min_price.toFixed(2)}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={filters.priceRange[0] * 100}
|
value={value.min_price * 100}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateFilter("priceRange", [
|
updateFilter(
|
||||||
Number(e.target.value) / 100,
|
"min_price",
|
||||||
filters.priceRange[1],
|
Math.min(Number(e.target.value) / 100, value.max_price),
|
||||||
])
|
)
|
||||||
}
|
}
|
||||||
className="scan-range-slider"
|
className="scan-range-slider"
|
||||||
/>
|
/>
|
||||||
@@ -142,16 +133,16 @@ export function ScanFilterPanel({
|
|||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={filters.priceRange[1] * 100}
|
value={value.max_price * 100}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateFilter("priceRange", [
|
updateFilter(
|
||||||
filters.priceRange[0],
|
"max_price",
|
||||||
Number(e.target.value) / 100,
|
Math.max(Number(e.target.value) / 100, value.min_price),
|
||||||
])
|
)
|
||||||
}
|
}
|
||||||
className="scan-range-slider"
|
className="scan-range-slider"
|
||||||
/>
|
/>
|
||||||
<span>{filters.priceRange[1].toFixed(2)}</span>
|
<span>{value.max_price.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,13 +152,15 @@ export function ScanFilterPanel({
|
|||||||
{isEn ? "Min Edge" : "最小边际优势"}
|
{isEn ? "Min Edge" : "最小边际优势"}
|
||||||
</span>
|
</span>
|
||||||
<div className="scan-range-display">
|
<div className="scan-range-display">
|
||||||
<span>{filters.minEdge}%</span>
|
<span>{value.min_edge_pct}%</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={20}
|
max={20}
|
||||||
value={filters.minEdge}
|
value={value.min_edge_pct}
|
||||||
onChange={(e) => updateFilter("minEdge", Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
updateFilter("min_edge_pct", Number(e.target.value))
|
||||||
|
}
|
||||||
className="scan-range-slider"
|
className="scan-range-slider"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,10 +172,17 @@ export function ScanFilterPanel({
|
|||||||
{isEn ? "High Liquidity Only" : "只看高流动性"}
|
{isEn ? "High Liquidity Only" : "只看高流动性"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className={`scan-toggle ${filters.highLiquidityOnly ? "active" : ""}`}
|
className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
updateFilter("highLiquidityOnly", !filters.highLiquidityOnly)
|
const nextValue = !value.high_liquidity_only;
|
||||||
}
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
high_liquidity_only: nextValue,
|
||||||
|
min_liquidity: nextValue
|
||||||
|
? Math.max(value.min_liquidity, 5000)
|
||||||
|
: 500,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="scan-toggle-knob" />
|
<span className="scan-toggle-knob" />
|
||||||
</button>
|
</button>
|
||||||
@@ -195,11 +195,11 @@ export function ScanFilterPanel({
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
className="scan-select"
|
className="scan-select"
|
||||||
value={filters.marketType}
|
value={value.market_type}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateFilter(
|
updateFilter(
|
||||||
"marketType",
|
"market_type",
|
||||||
e.target.value as FilterState["marketType"],
|
e.target.value as FilterState["market_type"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -217,11 +217,11 @@ export function ScanFilterPanel({
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
className="scan-select"
|
className="scan-select"
|
||||||
value={filters.timeRange}
|
value={value.time_range}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateFilter(
|
updateFilter(
|
||||||
"timeRange",
|
"time_range",
|
||||||
e.target.value as FilterState["timeRange"],
|
e.target.value as FilterState["time_range"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -235,7 +235,7 @@ export function ScanFilterPanel({
|
|||||||
{/* === CTA === */}
|
{/* === CTA === */}
|
||||||
<button
|
<button
|
||||||
className="scan-cta-button"
|
className="scan-cta-button"
|
||||||
onClick={() => onScan?.(filters)}
|
onClick={() => onScan?.(value)}
|
||||||
disabled={isScanning}
|
disabled={isScanning}
|
||||||
>
|
>
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
|
|||||||
@@ -3,17 +3,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
|
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
interface KPIData {
|
type KPIData = ScanTerminalResponse["summary"];
|
||||||
recommendedCount: number;
|
|
||||||
recommendedDelta?: number;
|
|
||||||
avgEdge: number | null;
|
|
||||||
avgEdgeDelta?: number | null;
|
|
||||||
totalWinRate: number | null;
|
|
||||||
tradableMarkets: number;
|
|
||||||
filteredTotal: number;
|
|
||||||
totalVolume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVolume(value: number): string {
|
function formatVolume(value: number): string {
|
||||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||||
@@ -29,42 +21,41 @@ export function ScanKPIBar({ data }: { data: KPIData }) {
|
|||||||
{
|
{
|
||||||
icon: Target,
|
icon: Target,
|
||||||
label: isEn ? "Recommended" : "推荐机会",
|
label: isEn ? "Recommended" : "推荐机会",
|
||||||
value: String(data.recommendedCount),
|
value: String(data.recommended_count),
|
||||||
delta:
|
delta: `${isEn ? "Showing" : "当前展示"} ${data.visible_count}`,
|
||||||
data.recommendedDelta != null
|
|
||||||
? `${isEn ? "vs last" : "较上次"} ${data.recommendedDelta > 0 ? "+" : ""}${data.recommendedDelta}`
|
|
||||||
: null,
|
|
||||||
accent: "green" as const,
|
accent: "green" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
label: isEn ? "Avg Edge" : "平均边际优势",
|
label: isEn ? "Avg Edge" : "平均边际优势",
|
||||||
value: data.avgEdge != null ? `+${data.avgEdge.toFixed(1)}%` : "--",
|
value:
|
||||||
delta:
|
data.avg_edge_percent != null
|
||||||
data.avgEdgeDelta != null
|
? `+${data.avg_edge_percent.toFixed(1)}%`
|
||||||
? `${isEn ? "vs last" : "较上次"} ${data.avgEdgeDelta > 0 ? "+" : ""}${data.avgEdgeDelta.toFixed(1)}%`
|
: "--",
|
||||||
: null,
|
delta: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
|
||||||
accent: "green" as const,
|
accent: "green" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
label: isEn ? "Total Win Rate" : "总胜率预测",
|
label: isEn ? "Avg Confidence" : "平均主信号置信度",
|
||||||
value:
|
value:
|
||||||
data.totalWinRate != null ? `+${data.totalWinRate.toFixed(1)}%` : "--",
|
data.avg_primary_confidence != null
|
||||||
delta: isEn ? "Model confidence" : "模型全分配",
|
? `${data.avg_primary_confidence.toFixed(0)}`
|
||||||
|
: "--",
|
||||||
|
delta: isEn ? "Main signal score" : "主信号评分",
|
||||||
accent: "green" as const,
|
accent: "green" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
label: isEn ? "Tradable Markets" : "可交易市场",
|
||||||
value: String(data.tradableMarkets),
|
value: String(data.tradable_market_count),
|
||||||
delta: `${isEn ? "Filtered" : "过滤后"} / ${data.filteredTotal}`,
|
delta: data.resolved_market_type || "maxtemp",
|
||||||
accent: "purple" as const,
|
accent: "purple" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
label: isEn ? "Total Volume" : "总成交量",
|
label: isEn ? "Total Volume" : "总成交量",
|
||||||
value: formatVolume(data.totalVolume),
|
value: formatVolume(data.total_volume),
|
||||||
delta: isEn ? "Past 24h" : "过去 24 小时",
|
delta: isEn ? "Past 24h" : "过去 24 小时",
|
||||||
accent: "purple" as const,
|
accent: "purple" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
startTransition,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useEffectEvent,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import styles from "./Dashboard.module.css";
|
||||||
|
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||||
|
import modalChromeStyles from "./ModalChrome.module.css";
|
||||||
|
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||||
|
import {
|
||||||
|
FilterState,
|
||||||
|
ScanFilterPanel,
|
||||||
|
} from "@/components/dashboard/ScanFilterPanel";
|
||||||
|
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
|
||||||
|
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
|
||||||
|
import { DashboardStoreProvider, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
|
import { dashboardClient } from "@/lib/dashboard-client";
|
||||||
|
import type {
|
||||||
|
MarketScan,
|
||||||
|
PrimarySignal,
|
||||||
|
ScanOpportunityRow,
|
||||||
|
ScanTerminalResponse,
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
|
import {
|
||||||
|
getLocalizedAirportName,
|
||||||
|
getLocalizedCityName,
|
||||||
|
} from "@/lib/dashboard-home-copy";
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: FilterState = {
|
||||||
|
scan_mode: "tradable",
|
||||||
|
min_price: 0.05,
|
||||||
|
max_price: 0.95,
|
||||||
|
min_edge_pct: 2,
|
||||||
|
min_liquidity: 500,
|
||||||
|
high_liquidity_only: false,
|
||||||
|
market_type: "maxtemp",
|
||||||
|
time_range: "today",
|
||||||
|
limit: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPercent(value?: number | null, signed = false) {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!signed) return `${numeric.toFixed(1)}%`;
|
||||||
|
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProbability(value?: number | null) {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
|
return `${(Number(value) * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(value?: number | null) {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
|
return `${Math.round(Number(value) * 100)}¢`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVolume(value?: number | null) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) return "--";
|
||||||
|
if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`;
|
||||||
|
return `$${numeric.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(value?: number | null, locale = "zh-CN") {
|
||||||
|
if (value == null || !Number.isFinite(Number(value))) return "--";
|
||||||
|
const numeric = Math.max(0, Math.round(Number(value)));
|
||||||
|
if (locale === "en-US") return `${numeric}m`;
|
||||||
|
return `${numeric} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTone(score?: number | null) {
|
||||||
|
const numeric = Number(score || 0);
|
||||||
|
if (numeric >= 85) return "green";
|
||||||
|
if (numeric >= 70) return "amber";
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceDotCount(score?: number | null) {
|
||||||
|
const numeric = Number(score || 0);
|
||||||
|
if (numeric >= 90) return 5;
|
||||||
|
if (numeric >= 80) return 4;
|
||||||
|
if (numeric >= 70) return 3;
|
||||||
|
if (numeric >= 60) return 2;
|
||||||
|
if (numeric > 0) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSideRow(
|
||||||
|
marketScan: MarketScan | null | undefined,
|
||||||
|
selectedRow: ScanOpportunityRow,
|
||||||
|
side: "yes" | "no",
|
||||||
|
) {
|
||||||
|
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan?.scan_rows : [];
|
||||||
|
const matched = rows.find(
|
||||||
|
(row) =>
|
||||||
|
row.market_slug === selectedRow.market_slug &&
|
||||||
|
row.selected_date === selectedRow.selected_date &&
|
||||||
|
row.side === side,
|
||||||
|
);
|
||||||
|
if (matched) return matched;
|
||||||
|
if (selectedRow.side === side) return selectedRow;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanel({
|
||||||
|
row,
|
||||||
|
marketScan,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
row: ScanOpportunityRow | null;
|
||||||
|
marketScan?: MarketScan | null;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return (
|
||||||
|
<aside className="scan-detail-panel">
|
||||||
|
<div className="scan-detail-empty">
|
||||||
|
{isEn ? "Select a row to inspect the main signal." : "选择一条机会,查看主信号详情。"}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedCityName = getLocalizedCityName(
|
||||||
|
row.city,
|
||||||
|
row.city_display_name || row.display_name || row.city,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
const localizedAirport = getLocalizedAirportName(
|
||||||
|
row.city,
|
||||||
|
row.airport || "",
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined;
|
||||||
|
const displayRow = detailSignal && detailSignal.market_slug === row.market_slug ? detailSignal : row;
|
||||||
|
const distributionBias = marketScan?.distribution_bias || row.distribution_bias || null;
|
||||||
|
const yesRow = getSideRow(marketScan, row, "yes");
|
||||||
|
const noRow = getSideRow(marketScan, row, "no");
|
||||||
|
const tone = scoreTone(displayRow.final_score);
|
||||||
|
const filledDots = confidenceDotCount(displayRow.final_score);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="scan-detail-panel">
|
||||||
|
<div className="scan-detail-header">
|
||||||
|
<div className="scan-detail-hero-placeholder" />
|
||||||
|
<div className="scan-detail-city-info">
|
||||||
|
<div className="scan-detail-city-name">{localizedCityName}</div>
|
||||||
|
<div className="scan-detail-city-sub">
|
||||||
|
{displayRow.market_question || displayRow.target_label || "--"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-detail-volume">
|
||||||
|
{formatVolume(displayRow.volume)}{" "}
|
||||||
|
{isEn ? "24h volume" : "24h 成交量"}
|
||||||
|
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scan-detail-section">
|
||||||
|
<div className="scan-detail-section-title">
|
||||||
|
{isEn ? "Current Context" : "当前概况"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-conditions-table">
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Local Time" : "当地时间"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">{row.local_time || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Current Temp" : "当前温度"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">
|
||||||
|
{row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Day High (So Far)" : "今日高点(至今)"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">
|
||||||
|
{row.current_max_so_far != null
|
||||||
|
? `${row.current_max_so_far}${row.temp_symbol || ""}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Target" : "目标温度"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">
|
||||||
|
{displayRow.target_label || "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Gap To Target" : "距目标"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"scan-condition-value",
|
||||||
|
Number(displayRow.gap_to_target || 0) <= 0
|
||||||
|
? "accent-green"
|
||||||
|
: "accent-red",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayRow.gap_to_target != null
|
||||||
|
? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Window Left" : "剩余有效时间"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">
|
||||||
|
{formatMinutes(displayRow.remaining_window_minutes, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Bias" : "分布偏移"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">
|
||||||
|
{distributionBias?.direction || "--"} ·{" "}
|
||||||
|
{distributionBias?.score != null
|
||||||
|
? distributionBias.score.toFixed(0)
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-condition-item">
|
||||||
|
<span className="scan-condition-label">
|
||||||
|
{isEn ? "Airport" : "机场锚点"}
|
||||||
|
</span>
|
||||||
|
<span className="scan-condition-value">{localizedAirport || "--"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scan-detail-section">
|
||||||
|
<div className="scan-detail-section-title">
|
||||||
|
{isEn ? "Recommended Trade" : "推荐交易"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-trade-cards">
|
||||||
|
<div className="scan-trade-card yes">
|
||||||
|
<div className="scan-trade-card-title">BUY YES</div>
|
||||||
|
<div className="scan-trade-card-price">
|
||||||
|
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} / {formatProbability(yesRow?.model_probability)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"scan-trade-card-edge",
|
||||||
|
Number(yesRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPercent(yesRow?.edge_percent, true)}
|
||||||
|
</div>
|
||||||
|
<div className="scan-trade-card-note">
|
||||||
|
{isEn ? "Spread" : "点差"} {formatPrice(yesRow?.spread)} ·{" "}
|
||||||
|
{isEn ? "Liquidity" : "流动性"} {formatVolume(yesRow?.book_liquidity ?? yesRow?.market_liquidity)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scan-trade-card no">
|
||||||
|
<div className="scan-trade-card-title">BUY NO</div>
|
||||||
|
<div className="scan-trade-card-price">
|
||||||
|
{formatPrice(noRow?.ask ?? marketScan?.no_buy)} / {formatProbability(noRow?.model_probability)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"scan-trade-card-edge",
|
||||||
|
Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPercent(noRow?.edge_percent, true)}
|
||||||
|
</div>
|
||||||
|
<div className="scan-trade-card-note">
|
||||||
|
{isEn ? "Spread" : "点差"} {formatPrice(noRow?.spread)} ·{" "}
|
||||||
|
{isEn ? "Liquidity" : "流动性"} {formatVolume(noRow?.book_liquidity ?? noRow?.market_liquidity)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scan-detail-score">
|
||||||
|
<div>
|
||||||
|
<div className="scan-detail-score-big">
|
||||||
|
{Number(displayRow.final_score || 0).toFixed(0)}
|
||||||
|
<span className="scan-detail-score-suffix">/100</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-detail-score-label">
|
||||||
|
{isEn ? "Composite signal score" : "综合主信号评分"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scan-confidence-dots">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => {
|
||||||
|
const filled = index < filledDots;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
"scan-confidence-dot",
|
||||||
|
filled && "filled",
|
||||||
|
filled && tone === "amber" && "amber",
|
||||||
|
filled && tone === "red" && "red",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanTerminalScreen() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||||
|
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||||
|
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||||
|
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
||||||
|
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deferredRows = useDeferredValue(terminalData?.rows || []);
|
||||||
|
const selectedRow = useMemo(() => {
|
||||||
|
if (!deferredRows.length) return null;
|
||||||
|
return (
|
||||||
|
deferredRows.find((row) => row.id === selectedRowId) ||
|
||||||
|
deferredRows[0] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [deferredRows, selectedRowId]);
|
||||||
|
|
||||||
|
const fetchTerminal = useEffectEvent(async (filters: FilterState, force = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await dashboardClient.getScanTerminal(filters, { force });
|
||||||
|
startTransition(() => {
|
||||||
|
setTerminalData(response);
|
||||||
|
setActiveFilters(filters);
|
||||||
|
setSelectedRowId((current) => {
|
||||||
|
if (current && response.rows.some((row) => row.id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return response.top_signal?.id || response.rows[0]?.id || null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
setError(String(fetchError));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchDetail = useEffectEvent(async (row: ScanOpportunityRow) => {
|
||||||
|
if (!row.market_slug || !row.selected_date) return;
|
||||||
|
if (detailByRowId[row.id] !== undefined) return;
|
||||||
|
setDetailLoadingId(row.id);
|
||||||
|
try {
|
||||||
|
const response = await dashboardClient.getCityMarketScan(row.city, {
|
||||||
|
force: false,
|
||||||
|
marketSlug: row.market_slug,
|
||||||
|
targetDate: row.selected_date,
|
||||||
|
});
|
||||||
|
setDetailByRowId((current) => ({
|
||||||
|
...current,
|
||||||
|
[row.id]: response.market_scan || null,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setDetailByRowId((current) => ({
|
||||||
|
...current,
|
||||||
|
[row.id]: null,
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setDetailLoadingId((current) => (current === row.id ? null : current));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTerminal(DEFAULT_FILTERS, false);
|
||||||
|
}, [fetchTerminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void fetchTerminal(activeFilters, false);
|
||||||
|
}, 30_000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [activeFilters, fetchTerminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRow) return;
|
||||||
|
void fetchDetail(selectedRow);
|
||||||
|
}, [selectedRow, fetchDetail]);
|
||||||
|
|
||||||
|
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.root,
|
||||||
|
detailChromeStyles.root,
|
||||||
|
modalChromeStyles.root,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HeaderBar
|
||||||
|
refreshAction={() => fetchTerminal(activeFilters, true)}
|
||||||
|
refreshSpinning={loading || store.loadingState.refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scan-terminal">
|
||||||
|
<ScanFilterPanel
|
||||||
|
value={draftFilters}
|
||||||
|
onChange={setDraftFilters}
|
||||||
|
onScan={(filters) => {
|
||||||
|
setDraftFilters(filters);
|
||||||
|
void fetchTerminal(filters, true);
|
||||||
|
}}
|
||||||
|
isScanning={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="scan-data-grid">
|
||||||
|
<div className="scan-data-grid-header">
|
||||||
|
<div>
|
||||||
|
<div className="scan-data-grid-title">
|
||||||
|
{isEn ? "Tradable Opportunities" : "可交易机会"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-data-grid-subtitle">
|
||||||
|
{isEn
|
||||||
|
? "REST-only EMOS scan with one main signal per city/date."
|
||||||
|
: "基于 EMOS 分布与 CLOB REST 盘口,只输出每个城市/日期的单一主信号。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scan-data-grid-controls">
|
||||||
|
<span className="scan-status-badge tone-neutral">
|
||||||
|
{isEn ? "Updated" : "数据时间"} ·{" "}
|
||||||
|
{terminalData?.generated_at
|
||||||
|
? terminalData.generated_at.replace("T", " ").slice(0, 19)
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScanKPIBar
|
||||||
|
data={
|
||||||
|
terminalData?.summary || {
|
||||||
|
recommended_count: 0,
|
||||||
|
visible_count: 0,
|
||||||
|
candidate_total: 0,
|
||||||
|
avg_edge_percent: null,
|
||||||
|
avg_primary_confidence: null,
|
||||||
|
tradable_market_count: 0,
|
||||||
|
total_volume: 0,
|
||||||
|
resolved_market_type: "maxtemp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scan-view-tabs">
|
||||||
|
<button type="button" className="scan-view-tab active">
|
||||||
|
{isEn ? "Opportunity List" : "机会列表"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="scan-detail-empty">
|
||||||
|
{isEn ? "Scan failed." : "扫描失败。"} {error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<OpportunityTable
|
||||||
|
rows={deferredRows}
|
||||||
|
selectedRowId={selectedRowId}
|
||||||
|
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<DetailPanel
|
||||||
|
row={selectedRow}
|
||||||
|
marketScan={selectedDetail}
|
||||||
|
loading={detailLoadingId === selectedRow?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanTerminalDashboard() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<DashboardStoreProvider>
|
||||||
|
<ScanTerminalScreen />
|
||||||
|
</DashboardStoreProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
CitySummary,
|
CitySummary,
|
||||||
HistoryPayload,
|
HistoryPayload,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
|
ScanTerminalFilters,
|
||||||
|
ScanTerminalResponse,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
|
||||||
export type AssistantOpportunityContext = {
|
export type AssistantOpportunityContext = {
|
||||||
@@ -73,6 +75,7 @@ const pendingCityMarketScanRequests = new Map<
|
|||||||
}>
|
}>
|
||||||
>();
|
>();
|
||||||
const pendingAssistantRequests = new Map<string, Promise<AssistantChatResponse>>();
|
const pendingAssistantRequests = new Map<string, Promise<AssistantChatResponse>>();
|
||||||
|
const pendingScanTerminalRequests = new Map<string, Promise<ScanTerminalResponse>>();
|
||||||
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
|
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
|
||||||
|
|
||||||
type CityCacheMeta = {
|
type CityCacheMeta = {
|
||||||
@@ -350,6 +353,41 @@ export const dashboardClient = {
|
|||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getScanTerminal(
|
||||||
|
filters: ScanTerminalFilters,
|
||||||
|
options?: { force?: boolean },
|
||||||
|
) {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
scan_mode: String(filters.scan_mode),
|
||||||
|
min_price: String(filters.min_price),
|
||||||
|
max_price: String(filters.max_price),
|
||||||
|
min_edge_pct: String(filters.min_edge_pct),
|
||||||
|
min_liquidity: String(filters.min_liquidity),
|
||||||
|
high_liquidity_only: String(filters.high_liquidity_only),
|
||||||
|
market_type: String(filters.market_type),
|
||||||
|
time_range: String(filters.time_range),
|
||||||
|
limit: String(filters.limit),
|
||||||
|
force_refresh: String(force),
|
||||||
|
});
|
||||||
|
const requestKey = `${params.toString()}::${force ? "force" : "cached"}`;
|
||||||
|
if (!force) {
|
||||||
|
const existing = pendingScanTerminalRequests.get(requestKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const request = fetchJson<ScanTerminalResponse>(
|
||||||
|
`/api/scan/terminal?${params.toString()}`,
|
||||||
|
).finally(() => {
|
||||||
|
pendingScanTerminalRequests.delete(requestKey);
|
||||||
|
});
|
||||||
|
if (!force) {
|
||||||
|
pendingScanTerminalRequests.set(requestKey, request);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
|
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
|
||||||
const includeRecords = options?.includeRecords === true;
|
const includeRecords = options?.includeRecords === true;
|
||||||
const requestKey = `${normalizeCityName(cityName)}::${
|
const requestKey = `${normalizeCityName(cityName)}::${
|
||||||
|
|||||||
@@ -399,6 +399,134 @@ export interface MarketScan {
|
|||||||
recent_trades?: unknown[];
|
recent_trades?: unknown[];
|
||||||
scan_scope?: "lite" | "full" | string | null;
|
scan_scope?: "lite" | "full" | string | null;
|
||||||
websocket?: Record<string, unknown>;
|
websocket?: Record<string, unknown>;
|
||||||
|
distribution_bias?: DistributionBias | null;
|
||||||
|
window_phase?: string | null;
|
||||||
|
window_score?: number | null;
|
||||||
|
primary_signal?: PrimarySignal | null;
|
||||||
|
signal_status?: string | null;
|
||||||
|
candidate_count?: number | null;
|
||||||
|
scan_rows?: ScanOpportunityRow[] | null;
|
||||||
|
resolved_market_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBias {
|
||||||
|
available?: boolean;
|
||||||
|
value?: number | null;
|
||||||
|
score?: number | null;
|
||||||
|
direction?: "hotter" | "colder" | "balanced" | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanTerminalFilters {
|
||||||
|
scan_mode: "tradable" | "early" | "touch" | "trend";
|
||||||
|
min_price: number;
|
||||||
|
max_price: number;
|
||||||
|
min_edge_pct: number;
|
||||||
|
min_liquidity: number;
|
||||||
|
high_liquidity_only: boolean;
|
||||||
|
market_type: "maxtemp" | "all" | string;
|
||||||
|
time_range: "today" | "tomorrow" | "week" | string;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanOpportunityRow {
|
||||||
|
id: string;
|
||||||
|
rank?: number | null;
|
||||||
|
city: string;
|
||||||
|
city_display_name?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
selected_date?: string | null;
|
||||||
|
local_date?: string | null;
|
||||||
|
local_time?: string | null;
|
||||||
|
temp_symbol?: string | null;
|
||||||
|
current_temp?: number | null;
|
||||||
|
current_max_so_far?: number | null;
|
||||||
|
deb_prediction?: number | null;
|
||||||
|
airport?: string | null;
|
||||||
|
risk_level?: RiskLevel | null;
|
||||||
|
market_slug?: string | null;
|
||||||
|
market_question?: string | null;
|
||||||
|
market_url?: string | null;
|
||||||
|
market_key?: string | null;
|
||||||
|
side?: "yes" | "no" | string | null;
|
||||||
|
action?: string | null;
|
||||||
|
market_direction?: string | null;
|
||||||
|
temperature_direction?: string | null;
|
||||||
|
target_label?: string | null;
|
||||||
|
target_value?: number | null;
|
||||||
|
target_threshold?: number | null;
|
||||||
|
target_lower?: number | null;
|
||||||
|
target_upper?: number | null;
|
||||||
|
target_unit?: string | null;
|
||||||
|
model_probability?: number | null;
|
||||||
|
market_probability?: number | null;
|
||||||
|
model_event_probability?: number | null;
|
||||||
|
market_event_probability?: number | null;
|
||||||
|
gap?: number | null;
|
||||||
|
signed_gap?: number | null;
|
||||||
|
yes_token_id?: string | null;
|
||||||
|
no_token_id?: string | null;
|
||||||
|
yes_ask?: number | null;
|
||||||
|
yes_bid?: number | null;
|
||||||
|
no_ask?: number | null;
|
||||||
|
no_bid?: number | null;
|
||||||
|
ask?: number | null;
|
||||||
|
bid?: number | null;
|
||||||
|
midpoint?: number | null;
|
||||||
|
spread?: number | null;
|
||||||
|
book_liquidity?: number | null;
|
||||||
|
market_liquidity?: number | null;
|
||||||
|
volume?: number | null;
|
||||||
|
quote_source?: string | null;
|
||||||
|
quote_age_ms?: number | null;
|
||||||
|
edge?: number | null;
|
||||||
|
edge_percent?: number | null;
|
||||||
|
edge_score?: number | null;
|
||||||
|
bias_score?: number | null;
|
||||||
|
distribution_bias?: DistributionBias | null;
|
||||||
|
distribution_bias_direction?: string | null;
|
||||||
|
distribution_bias_score?: number | null;
|
||||||
|
distribution_bias_available?: boolean;
|
||||||
|
window_phase?: string | null;
|
||||||
|
window_score?: number | null;
|
||||||
|
remaining_window_minutes?: number | null;
|
||||||
|
liquidity_score?: number | null;
|
||||||
|
price_usefulness_score?: number | null;
|
||||||
|
spread_penalty?: number | null;
|
||||||
|
final_score?: number | null;
|
||||||
|
current_reference?: number | null;
|
||||||
|
gap_to_target?: number | null;
|
||||||
|
touch_distance?: number | null;
|
||||||
|
trend_alignment?: boolean;
|
||||||
|
tradable?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
closed?: boolean;
|
||||||
|
accepting_orders?: boolean;
|
||||||
|
enable_order_book?: boolean;
|
||||||
|
is_primary_market?: boolean;
|
||||||
|
is_primary_signal?: boolean;
|
||||||
|
signal_confidence?: number | null;
|
||||||
|
signal_status?: string | null;
|
||||||
|
candidate_count?: number | null;
|
||||||
|
resolved_market_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrimarySignal extends ScanOpportunityRow {}
|
||||||
|
|
||||||
|
export interface ScanTerminalResponse {
|
||||||
|
generated_at: string;
|
||||||
|
filters: ScanTerminalFilters;
|
||||||
|
summary: {
|
||||||
|
recommended_count: number;
|
||||||
|
visible_count: number;
|
||||||
|
candidate_total: number;
|
||||||
|
avg_edge_percent?: number | null;
|
||||||
|
avg_primary_confidence?: number | null;
|
||||||
|
tradable_market_count: number;
|
||||||
|
total_volume: number;
|
||||||
|
resolved_market_type?: string | null;
|
||||||
|
};
|
||||||
|
top_signal?: PrimarySignal | null;
|
||||||
|
rows: ScanOpportunityRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntradayMeteorologySignal {
|
export interface IntradayMeteorologySignal {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -484,3 +484,193 @@ def test_find_primary_market_prefers_preferred_temperature_and_cache_key():
|
|||||||
assert reason_22 is None
|
assert reason_22 is None
|
||||||
assert selected_27["slug"] == "highest-temperature-in-madrid-on-april-23-2026-27c"
|
assert selected_27["slug"] == "highest-temperature-in-madrid-on-april-23-2026-27c"
|
||||||
assert selected_22["slug"] == "highest-temperature-in-madrid-on-april-23-2026-22corbelow"
|
assert selected_22["slug"] == "highest-temperature-in-madrid-on-april-23-2026-22corbelow"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scan_test_layer():
|
||||||
|
layer = PolymarketReadOnlyLayer()
|
||||||
|
markets = [
|
||||||
|
{
|
||||||
|
"id": "m-above-14",
|
||||||
|
"slug": "highest-temperature-in-wellington-on-april-24-2026-14c-or-higher",
|
||||||
|
"question": "Will the highest temperature in Wellington be 14C or higher on April 24?",
|
||||||
|
"active": True,
|
||||||
|
"closed": False,
|
||||||
|
"acceptingOrders": True,
|
||||||
|
"enableOrderBook": True,
|
||||||
|
"liquidityNum": 12000,
|
||||||
|
"volumeNum": 4000,
|
||||||
|
"_model_prob": 0.60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m-below-16",
|
||||||
|
"slug": "highest-temperature-in-wellington-on-april-24-2026-16c-or-lower",
|
||||||
|
"question": "Will the highest temperature in Wellington be 16C or lower on April 24?",
|
||||||
|
"active": True,
|
||||||
|
"closed": False,
|
||||||
|
"acceptingOrders": True,
|
||||||
|
"enableOrderBook": True,
|
||||||
|
"liquidityNum": 9000,
|
||||||
|
"volumeNum": 3500,
|
||||||
|
"_model_prob": 0.30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m-above-17",
|
||||||
|
"slug": "highest-temperature-in-wellington-on-april-24-2026-17c-or-higher",
|
||||||
|
"question": "Will the highest temperature in Wellington be 17C or higher on April 24?",
|
||||||
|
"active": True,
|
||||||
|
"closed": False,
|
||||||
|
"acceptingOrders": True,
|
||||||
|
"enableOrderBook": True,
|
||||||
|
"liquidityNum": 7000,
|
||||||
|
"volumeNum": 2800,
|
||||||
|
"_model_prob": 0.20,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
token_map = {
|
||||||
|
"m-above-14": {"yes": "yes-14", "no": "no-14"},
|
||||||
|
"m-below-16": {"yes": "yes-16", "no": "no-16"},
|
||||||
|
"m-above-17": {"yes": "yes-17", "no": "no-17"},
|
||||||
|
}
|
||||||
|
quote_map = {
|
||||||
|
"yes-14": {"buy": 0.48, "sell": 0.46, "midpoint": 0.40, "spread": 0.02, "book_liquidity": 14000},
|
||||||
|
"no-14": {"buy": 0.54, "sell": 0.52, "midpoint": 0.60, "spread": 0.02, "book_liquidity": 14000},
|
||||||
|
"yes-16": {"buy": 0.42, "sell": 0.40, "midpoint": 0.50, "spread": 0.02, "book_liquidity": 9000},
|
||||||
|
"no-16": {"buy": 0.56, "sell": 0.54, "midpoint": 0.50, "spread": 0.02, "book_liquidity": 9000},
|
||||||
|
"yes-17": {"buy": 0.08, "sell": 0.07, "midpoint": 0.10, "spread": 0.01, "book_liquidity": 7500},
|
||||||
|
"no-17": {"buy": 0.92, "sell": 0.91, "midpoint": 0.90, "spread": 0.01, "book_liquidity": 7500},
|
||||||
|
}
|
||||||
|
|
||||||
|
layer._collect_related_temperature_markets = lambda **_kwargs: markets
|
||||||
|
layer._aggregate_distribution_probability_for_market = (
|
||||||
|
lambda market, **_kwargs: market.get("_model_prob")
|
||||||
|
)
|
||||||
|
layer._extract_market_tokens = lambda market: [
|
||||||
|
{"outcome": "Yes", "token_id": token_map[market["id"]]["yes"]},
|
||||||
|
{"outcome": "No", "token_id": token_map[market["id"]]["no"]},
|
||||||
|
]
|
||||||
|
layer._batch_get_token_market_data = (
|
||||||
|
lambda token_ids, include_books=False: {
|
||||||
|
token_id: dict(quote_map[token_id])
|
||||||
|
for token_id in token_ids
|
||||||
|
if token_id in quote_map
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return layer, markets
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribution_scan_bias_flips_below_markets_into_hotter_signal():
|
||||||
|
layer, markets = _build_scan_test_layer()
|
||||||
|
|
||||||
|
scan = layer._build_distribution_scan_pack(
|
||||||
|
city_key="wellington",
|
||||||
|
target_date="2026-04-24",
|
||||||
|
primary_market=markets[0],
|
||||||
|
probability_distribution=[],
|
||||||
|
temp_symbol="°C",
|
||||||
|
scan_context={
|
||||||
|
"local_date": "2026-04-24",
|
||||||
|
"local_time": "13:10",
|
||||||
|
"peak": {"first_h": 14, "last_h": 16},
|
||||||
|
"current_max_so_far": 13.4,
|
||||||
|
"current_temp": 13.0,
|
||||||
|
"trend": {"recent": []},
|
||||||
|
"network_lead_signal": {},
|
||||||
|
},
|
||||||
|
scan_filters={"limit": 10},
|
||||||
|
)
|
||||||
|
|
||||||
|
bias = scan["distribution_bias"]
|
||||||
|
assert bias["available"] is True
|
||||||
|
assert bias["direction"] == "hotter"
|
||||||
|
assert bias["score"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribution_scan_returns_single_primary_signal_from_yes_no_mix():
|
||||||
|
layer, markets = _build_scan_test_layer()
|
||||||
|
|
||||||
|
scan = layer._build_distribution_scan_pack(
|
||||||
|
city_key="wellington",
|
||||||
|
target_date="2026-04-24",
|
||||||
|
primary_market=markets[0],
|
||||||
|
probability_distribution=[],
|
||||||
|
temp_symbol="°C",
|
||||||
|
scan_context={
|
||||||
|
"local_date": "2026-04-24",
|
||||||
|
"local_time": "13:10",
|
||||||
|
"peak": {"first_h": 14, "last_h": 16},
|
||||||
|
"current_max_so_far": 13.6,
|
||||||
|
"current_temp": 13.2,
|
||||||
|
"trend": {"recent": []},
|
||||||
|
"network_lead_signal": {},
|
||||||
|
},
|
||||||
|
scan_filters={"limit": 10, "min_edge_pct": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scan["candidate_count"] >= 2
|
||||||
|
assert isinstance(scan["primary_signal"], dict)
|
||||||
|
assert scan["primary_signal"]["side"] == "yes"
|
||||||
|
assert scan["primary_signal"]["id"] == scan["rows"][0]["id"]
|
||||||
|
assert scan["signal_status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribution_scan_hard_filters_block_unusable_extreme_quotes():
|
||||||
|
layer, markets = _build_scan_test_layer()
|
||||||
|
layer._batch_get_token_market_data = (
|
||||||
|
lambda token_ids, include_books=False: {
|
||||||
|
token_id: {
|
||||||
|
"buy": 0.99 if token_id.startswith("yes") else 0.01,
|
||||||
|
"sell": 0.95 if token_id.startswith("yes") else 0.0,
|
||||||
|
"midpoint": 0.97 if token_id.startswith("yes") else 0.03,
|
||||||
|
"spread": 0.04,
|
||||||
|
"book_liquidity": 100,
|
||||||
|
}
|
||||||
|
for token_id in token_ids
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
scan = layer._build_distribution_scan_pack(
|
||||||
|
city_key="wellington",
|
||||||
|
target_date="2026-04-24",
|
||||||
|
primary_market=markets[0],
|
||||||
|
probability_distribution=[],
|
||||||
|
temp_symbol="°C",
|
||||||
|
scan_context={
|
||||||
|
"local_date": "2026-04-24",
|
||||||
|
"local_time": "13:10",
|
||||||
|
"peak": {"first_h": 14, "last_h": 16},
|
||||||
|
"current_max_so_far": 13.6,
|
||||||
|
"current_temp": 13.2,
|
||||||
|
"trend": {"recent": []},
|
||||||
|
"network_lead_signal": {},
|
||||||
|
},
|
||||||
|
scan_filters={"limit": 10},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scan["signal_status"] == "no_signal"
|
||||||
|
assert scan["candidate_count"] == 0
|
||||||
|
assert scan["rows"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_token_market_data_falls_back_to_single_fetch_when_batch_fails():
|
||||||
|
layer = PolymarketReadOnlyLayer()
|
||||||
|
layer._clob_post = lambda *_args, **_kwargs: None
|
||||||
|
layer._fetch_token_market_data = lambda token_id: {
|
||||||
|
"buy": 0.33,
|
||||||
|
"sell": 0.31,
|
||||||
|
"midpoint": 0.32,
|
||||||
|
"quote_source": f"fallback:{token_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = layer._batch_get_token_market_data(["token-a", "token-b"])
|
||||||
|
|
||||||
|
assert result["token-a"]["midpoint"] == 0.32
|
||||||
|
assert result["token-b"]["quote_source"] == "fallback:token-b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_scan_filters_raises_liquidity_floor_when_high_liquidity_only():
|
||||||
|
layer = PolymarketReadOnlyLayer()
|
||||||
|
|
||||||
|
filters = layer._normalize_scan_filters({"high_liquidity_only": True})
|
||||||
|
|
||||||
|
assert filters["high_liquidity_only"] is True
|
||||||
|
assert filters["min_liquidity"] >= 5000
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from web.app import app
|
from web.app import app
|
||||||
import web.routes as routes
|
import web.routes as routes
|
||||||
|
from web.scan_terminal_service import _scan_terminal_cache_key
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository
|
from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository
|
||||||
|
|
||||||
@@ -260,6 +261,69 @@ def test_ops_truth_history_returns_filtered_rows(monkeypatch):
|
|||||||
assert payload["items"][0]["settlement_station_code"] == "RCSS"
|
assert payload["items"][0]["settlement_station_code"] == "RCSS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_terminal_endpoint_forwards_filters(monkeypatch):
|
||||||
|
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _fake_build_scan_terminal_payload(filters, *, force_refresh=False):
|
||||||
|
captured["filters"] = dict(filters)
|
||||||
|
captured["force_refresh"] = force_refresh
|
||||||
|
return {
|
||||||
|
"generated_at": "2026-04-23T00:00:00Z",
|
||||||
|
"filters": filters,
|
||||||
|
"summary": {
|
||||||
|
"recommended_count": 1,
|
||||||
|
"visible_count": 1,
|
||||||
|
"candidate_total": 3,
|
||||||
|
"avg_edge_percent": 4.2,
|
||||||
|
"avg_primary_confidence": 88.0,
|
||||||
|
"tradable_market_count": 1,
|
||||||
|
"total_volume": 1500,
|
||||||
|
"resolved_market_type": "maxtemp",
|
||||||
|
},
|
||||||
|
"top_signal": None,
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "build_scan_terminal_payload", _fake_build_scan_terminal_payload)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/scan/terminal?scan_mode=trend&min_price=0.1&max_price=0.8&min_edge_pct=3"
|
||||||
|
"&min_liquidity=700&high_liquidity_only=true&market_type=all&time_range=week&limit=12&force_refresh=true"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["summary"]["recommended_count"] == 1
|
||||||
|
assert captured["force_refresh"] is True
|
||||||
|
assert captured["filters"]["scan_mode"] == "trend"
|
||||||
|
assert captured["filters"]["market_type"] == "all"
|
||||||
|
assert captured["filters"]["time_range"] == "week"
|
||||||
|
assert captured["filters"]["limit"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_terminal_cache_key_includes_filter_dimensions():
|
||||||
|
first = _scan_terminal_cache_key(
|
||||||
|
{
|
||||||
|
"scan_mode": "tradable",
|
||||||
|
"time_range": "today",
|
||||||
|
"limit": 25,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
second = _scan_terminal_cache_key(
|
||||||
|
{
|
||||||
|
"scan_mode": "trend",
|
||||||
|
"time_range": "week",
|
||||||
|
"limit": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first != second
|
||||||
|
assert "trend" in second
|
||||||
|
assert "week" in second
|
||||||
|
|
||||||
|
|
||||||
def test_city_history_is_read_only_and_uses_sqlite_truth_and_features(monkeypatch):
|
def test_city_history_is_read_only_and_uses_sqlite_truth_and_features(monkeypatch):
|
||||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||||
|
|
||||||
|
|||||||
@@ -2805,6 +2805,7 @@ def _build_city_market_scan_payload(
|
|||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
lite: bool = False,
|
lite: bool = False,
|
||||||
|
scan_filters: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
city = str(data.get("name") or "").strip().lower()
|
city = str(data.get("name") or "").strip().lower()
|
||||||
local_date = str(data.get("local_date") or "").strip()
|
local_date = str(data.get("local_date") or "").strip()
|
||||||
@@ -2878,6 +2879,16 @@ def _build_city_market_scan_payload(
|
|||||||
for p in distribution_all[:8]
|
for p in distribution_all[:8]
|
||||||
if isinstance(p, dict)
|
if isinstance(p, dict)
|
||||||
]
|
]
|
||||||
|
current = data.get("current") or {}
|
||||||
|
scan_context = {
|
||||||
|
"local_date": data.get("local_date"),
|
||||||
|
"local_time": data.get("local_time"),
|
||||||
|
"peak": data.get("peak") or {},
|
||||||
|
"current_max_so_far": current.get("max_so_far"),
|
||||||
|
"current_temp": current.get("temp"),
|
||||||
|
"trend": data.get("trend") or {},
|
||||||
|
"network_lead_signal": data.get("network_lead_signal") or {},
|
||||||
|
}
|
||||||
market_scan = _market_layer.build_market_scan(
|
market_scan = _market_layer.build_market_scan(
|
||||||
city=data.get("name"),
|
city=data.get("name"),
|
||||||
target_date=selected_date or data.get("local_date"),
|
target_date=selected_date or data.get("local_date"),
|
||||||
@@ -2888,6 +2899,8 @@ def _build_city_market_scan_payload(
|
|||||||
fallback_sparkline=fallback_sparkline,
|
fallback_sparkline=fallback_sparkline,
|
||||||
forced_market_slug=market_slug,
|
forced_market_slug=market_slug,
|
||||||
include_related_buckets=not lite,
|
include_related_buckets=not lite,
|
||||||
|
scan_filters=scan_filters,
|
||||||
|
scan_context=scan_context,
|
||||||
)
|
)
|
||||||
if isinstance(market_scan, dict):
|
if isinstance(market_scan, dict):
|
||||||
market_scan["anchor_model"] = anchor_model
|
market_scan["anchor_model"] = anchor_model
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from web.analysis_service import (
|
|||||||
_build_city_market_scan_payload,
|
_build_city_market_scan_payload,
|
||||||
_build_city_summary_payload,
|
_build_city_summary_payload,
|
||||||
)
|
)
|
||||||
|
from web.scan_terminal_service import build_scan_terminal_payload
|
||||||
from web.core import (
|
from web.core import (
|
||||||
AnalyticsEventRequest,
|
AnalyticsEventRequest,
|
||||||
CITIES,
|
CITIES,
|
||||||
@@ -1694,3 +1695,36 @@ async def city_market_scan(
|
|||||||
lite,
|
lite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/scan/terminal")
|
||||||
|
async def scan_terminal(
|
||||||
|
request: Request,
|
||||||
|
scan_mode: str = "tradable",
|
||||||
|
min_price: float = 0.05,
|
||||||
|
max_price: float = 0.95,
|
||||||
|
min_edge_pct: float = 2.0,
|
||||||
|
min_liquidity: float = 500.0,
|
||||||
|
high_liquidity_only: bool = False,
|
||||||
|
market_type: str = "maxtemp",
|
||||||
|
time_range: str = "today",
|
||||||
|
limit: int = 25,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
):
|
||||||
|
_assert_entitlement(request)
|
||||||
|
filters = {
|
||||||
|
"scan_mode": scan_mode,
|
||||||
|
"min_price": min_price,
|
||||||
|
"max_price": max_price,
|
||||||
|
"min_edge_pct": min_edge_pct,
|
||||||
|
"min_liquidity": min_liquidity,
|
||||||
|
"high_liquidity_only": high_liquidity_only,
|
||||||
|
"market_type": market_type,
|
||||||
|
"time_range": time_range,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
return await run_in_threadpool(
|
||||||
|
build_scan_terminal_payload,
|
||||||
|
filters,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from web.analysis_service import _analyze, _build_city_market_scan_payload
|
||||||
|
from web.core import CITIES
|
||||||
|
|
||||||
|
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
|
||||||
|
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
|
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
||||||
|
5,
|
||||||
|
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_scan_terminal_filters(
|
||||||
|
raw_filters: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
raw = raw_filters if isinstance(raw_filters, dict) else {}
|
||||||
|
min_price = _safe_float(raw.get("min_price"))
|
||||||
|
max_price = _safe_float(raw.get("max_price"))
|
||||||
|
if min_price is None:
|
||||||
|
min_price = 0.05
|
||||||
|
if max_price is None:
|
||||||
|
max_price = 0.95
|
||||||
|
min_price = max(0.0, min(1.0, min_price))
|
||||||
|
max_price = max(0.0, min(1.0, max_price))
|
||||||
|
if min_price > max_price:
|
||||||
|
min_price, max_price = max_price, min_price
|
||||||
|
|
||||||
|
high_liquidity_only = bool(raw.get("high_liquidity_only"))
|
||||||
|
min_liquidity = _safe_float(raw.get("min_liquidity"))
|
||||||
|
if min_liquidity is None:
|
||||||
|
min_liquidity = 5000.0 if high_liquidity_only else 500.0
|
||||||
|
if high_liquidity_only:
|
||||||
|
min_liquidity = max(min_liquidity, 5000.0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scan_mode": str(raw.get("scan_mode") or "tradable").strip().lower()
|
||||||
|
or "tradable",
|
||||||
|
"min_price": float(min_price),
|
||||||
|
"max_price": float(max_price),
|
||||||
|
"min_edge_pct": max(0.0, _safe_float(raw.get("min_edge_pct")) or 2.0),
|
||||||
|
"min_liquidity": max(0.0, float(min_liquidity)),
|
||||||
|
"high_liquidity_only": high_liquidity_only,
|
||||||
|
"market_type": str(raw.get("market_type") or "maxtemp").strip().lower()
|
||||||
|
or "maxtemp",
|
||||||
|
"time_range": str(raw.get("time_range") or "today").strip().lower()
|
||||||
|
or "today",
|
||||||
|
"limit": max(1, min(_safe_int(raw.get("limit"), 25), 100)),
|
||||||
|
"max_spread": max(0.0, _safe_float(raw.get("max_spread")) or 0.03),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_terminal_cache_key(filters: Dict[str, Any]) -> str:
|
||||||
|
normalized = _normalize_scan_terminal_filters(filters)
|
||||||
|
return json.dumps(normalized, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached_scan_terminal_payload(
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
cache_key = _scan_terminal_cache_key(filters)
|
||||||
|
now = time.time()
|
||||||
|
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||||
|
cached = _SCAN_TERMINAL_CACHE.get(cache_key)
|
||||||
|
if not cached:
|
||||||
|
return None
|
||||||
|
cached_at = float(cached.get("t") or 0.0)
|
||||||
|
if now - cached_at >= float(SCAN_TERMINAL_PAYLOAD_TTL_SEC):
|
||||||
|
return None
|
||||||
|
payload = cached.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return dict(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cached_scan_terminal_payload(
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
cache_key = _scan_terminal_cache_key(filters)
|
||||||
|
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||||
|
_SCAN_TERMINAL_CACHE[cache_key] = {
|
||||||
|
"t": time.time(),
|
||||||
|
"payload": dict(payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_time_range_dates(data: Dict[str, Any], time_range: str) -> List[str]:
|
||||||
|
local_date = str(data.get("local_date") or "").strip()
|
||||||
|
multi_model_daily = data.get("multi_model_daily") or {}
|
||||||
|
available_dates = sorted(
|
||||||
|
str(date_key).strip()
|
||||||
|
for date_key in (multi_model_daily.keys() if isinstance(multi_model_daily, dict) else [])
|
||||||
|
if str(date_key).strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not local_date:
|
||||||
|
return available_dates[:1]
|
||||||
|
if time_range == "today":
|
||||||
|
return [local_date]
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_dt = datetime.fromisoformat(local_date)
|
||||||
|
except Exception:
|
||||||
|
return available_dates[:7] if time_range == "week" else available_dates[:1]
|
||||||
|
|
||||||
|
if time_range == "tomorrow":
|
||||||
|
target = (local_dt + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
if target in available_dates:
|
||||||
|
return [target]
|
||||||
|
future_dates = [date_key for date_key in available_dates if date_key > local_date]
|
||||||
|
return future_dates[:1]
|
||||||
|
|
||||||
|
if time_range == "week":
|
||||||
|
target_dates = [date_key for date_key in available_dates if date_key >= local_date]
|
||||||
|
if local_date not in target_dates:
|
||||||
|
target_dates.insert(0, local_date)
|
||||||
|
deduped: List[str] = []
|
||||||
|
for date_key in target_dates:
|
||||||
|
if date_key not in deduped:
|
||||||
|
deduped.append(date_key)
|
||||||
|
if len(deduped) >= 7:
|
||||||
|
break
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
return [local_date]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_terminal_row(
|
||||||
|
*,
|
||||||
|
city: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
scan: Dict[str, Any],
|
||||||
|
row: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
current = data.get("current") or {}
|
||||||
|
multi_model_daily = data.get("multi_model_daily") or {}
|
||||||
|
selected_date = str(row.get("selected_date") or scan.get("selected_date") or data.get("local_date") or "").strip()
|
||||||
|
daily_entry = multi_model_daily.get(selected_date) if isinstance(multi_model_daily, dict) else {}
|
||||||
|
if not isinstance(daily_entry, dict):
|
||||||
|
daily_entry = {}
|
||||||
|
|
||||||
|
display_name = str(data.get("display_name") or city).strip() or city
|
||||||
|
market_slug = str(row.get("market_slug") or "").strip()
|
||||||
|
side = str(row.get("side") or "").strip().lower()
|
||||||
|
edge_percent = _safe_float(row.get("edge_percent"))
|
||||||
|
final_score = _safe_float(row.get("final_score"))
|
||||||
|
volume = _safe_float(row.get("volume")) or 0.0
|
||||||
|
primary_signal = scan.get("primary_signal") or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
**row,
|
||||||
|
"id": str(row.get("id") or f"{city}|{selected_date}|{market_slug}|{side}"),
|
||||||
|
"city": city,
|
||||||
|
"city_display_name": display_name,
|
||||||
|
"selected_date": selected_date or None,
|
||||||
|
"local_date": data.get("local_date"),
|
||||||
|
"local_time": data.get("local_time"),
|
||||||
|
"temp_symbol": data.get("temp_symbol"),
|
||||||
|
"current_temp": current.get("temp"),
|
||||||
|
"current_max_so_far": current.get("max_so_far"),
|
||||||
|
"deb_prediction": ((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None)
|
||||||
|
or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None),
|
||||||
|
"display_name": display_name,
|
||||||
|
"airport": ((data.get("risk") or {}).get("airport") if isinstance(data.get("risk"), dict) else None),
|
||||||
|
"risk_level": ((data.get("risk") or {}).get("level") if isinstance(data.get("risk"), dict) else None),
|
||||||
|
"distribution_bias": scan.get("distribution_bias"),
|
||||||
|
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
||||||
|
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
||||||
|
"signal_status": scan.get("signal_status"),
|
||||||
|
"candidate_count": scan.get("candidate_count"),
|
||||||
|
"resolved_market_type": scan.get("resolved_market_type") or "maxtemp",
|
||||||
|
"market_key": f"{city}|{selected_date}|{market_slug}",
|
||||||
|
"is_primary_signal": bool(primary_signal and primary_signal.get("id") == row.get("id")),
|
||||||
|
"signal_confidence": final_score,
|
||||||
|
"edge_percent": edge_percent,
|
||||||
|
"final_score": final_score,
|
||||||
|
"volume": volume,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_city_terminal_rows(
|
||||||
|
city: str,
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
data = _analyze(
|
||||||
|
city,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
include_llm_commentary=False,
|
||||||
|
detail_mode="market",
|
||||||
|
)
|
||||||
|
target_dates = _resolve_time_range_dates(data, filters["time_range"])
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
primary_scores: List[float] = []
|
||||||
|
candidate_total = 0
|
||||||
|
|
||||||
|
for target_date in target_dates:
|
||||||
|
payload = _build_city_market_scan_payload(
|
||||||
|
data,
|
||||||
|
market_slug=None,
|
||||||
|
target_date=target_date,
|
||||||
|
lite=True,
|
||||||
|
scan_filters=filters,
|
||||||
|
)
|
||||||
|
scan = payload.get("market_scan") or {}
|
||||||
|
candidate_total += int(scan.get("candidate_count") or 0)
|
||||||
|
primary_signal = scan.get("primary_signal")
|
||||||
|
if not isinstance(primary_signal, dict) or not primary_signal:
|
||||||
|
continue
|
||||||
|
row = _build_terminal_row(
|
||||||
|
city=city,
|
||||||
|
data=data,
|
||||||
|
scan=scan,
|
||||||
|
row=primary_signal,
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
score = _safe_float(row.get("final_score"))
|
||||||
|
if score is not None:
|
||||||
|
primary_scores.append(score)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"city": city,
|
||||||
|
"rows": rows,
|
||||||
|
"candidate_total": candidate_total,
|
||||||
|
"primary_scores": primary_scores,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_scan_terminal_payload(
|
||||||
|
raw_filters: Optional[Dict[str, Any]] = None,
|
||||||
|
*,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
filters = _normalize_scan_terminal_filters(raw_filters)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _get_cached_scan_terminal_payload(filters)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
city_names = list(CITIES.keys())
|
||||||
|
max_workers = max(1, min(6, len(city_names)))
|
||||||
|
city_results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(
|
||||||
|
_scan_city_terminal_rows,
|
||||||
|
city_name,
|
||||||
|
filters,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
): city_name
|
||||||
|
for city_name in city_names
|
||||||
|
}
|
||||||
|
for future in as_completed(future_map):
|
||||||
|
city_name = future_map[future]
|
||||||
|
try:
|
||||||
|
city_results.append(future.result())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
||||||
|
|
||||||
|
primary_rows: List[Dict[str, Any]] = []
|
||||||
|
primary_scores: List[float] = []
|
||||||
|
candidate_total = 0
|
||||||
|
|
||||||
|
for result in city_results:
|
||||||
|
candidate_total += int(result.get("candidate_total") or 0)
|
||||||
|
primary_rows.extend(result.get("rows") or [])
|
||||||
|
primary_scores.extend(result.get("primary_scores") or [])
|
||||||
|
|
||||||
|
primary_rows.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
float(row.get("final_score") or 0.0),
|
||||||
|
float(row.get("edge_percent") or 0.0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked_rows: List[Dict[str, Any]] = []
|
||||||
|
for index, row in enumerate(primary_rows[: filters["limit"]], start=1):
|
||||||
|
ranked_rows.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"rank": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_market_volume: Dict[str, float] = {}
|
||||||
|
for row in primary_rows:
|
||||||
|
market_key = str(row.get("market_key") or row.get("id") or "").strip()
|
||||||
|
if not market_key:
|
||||||
|
continue
|
||||||
|
unique_market_volume[market_key] = max(
|
||||||
|
unique_market_volume.get(market_key, 0.0),
|
||||||
|
float(row.get("volume") or 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_edge = None
|
||||||
|
if primary_rows:
|
||||||
|
edge_values = [
|
||||||
|
float(row.get("edge_percent") or 0.0)
|
||||||
|
for row in primary_rows
|
||||||
|
if _safe_float(row.get("edge_percent")) is not None
|
||||||
|
]
|
||||||
|
if edge_values:
|
||||||
|
avg_edge = sum(edge_values) / len(edge_values)
|
||||||
|
|
||||||
|
avg_confidence = None
|
||||||
|
if primary_scores:
|
||||||
|
avg_confidence = sum(primary_scores) / len(primary_scores)
|
||||||
|
|
||||||
|
top_signal = ranked_rows[0] if ranked_rows else None
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"filters": filters,
|
||||||
|
"summary": {
|
||||||
|
"recommended_count": len(primary_rows),
|
||||||
|
"visible_count": len(ranked_rows),
|
||||||
|
"candidate_total": candidate_total,
|
||||||
|
"avg_edge_percent": avg_edge,
|
||||||
|
"avg_primary_confidence": avg_confidence,
|
||||||
|
"tradable_market_count": len(unique_market_volume),
|
||||||
|
"total_volume": sum(unique_market_volume.values()),
|
||||||
|
"resolved_market_type": "maxtemp",
|
||||||
|
},
|
||||||
|
"top_signal": top_signal,
|
||||||
|
"rows": ranked_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
_set_cached_scan_terminal_payload(filters, payload)
|
||||||
|
return payload
|
||||||
Reference in New Issue
Block a user