feat: implement comprehensive Polymarket weather analysis service with frontend dashboard and market scanning capabilities

This commit is contained in:
2569718930@qq.com
2026-04-23 20:45:32 +08:00
parent 4f57d4ed1a
commit 54b326ff42
12 changed files with 323 additions and 154 deletions
@@ -33,6 +33,11 @@ export async function GET(
params.set("market_slug", marketSlug);
}
const lite = req.nextUrl.searchParams.get("lite");
if (lite) {
params.set("lite", lite);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
try {
@@ -746,6 +746,7 @@ export function FutureForecastModal() {
dashboardClient
.getCityMarketScan(cityName, {
force: false,
lite: false,
marketSlug: detail.market_scan?.primary_market?.slug || null,
targetDate: dateStr,
})
@@ -766,7 +767,7 @@ export function FutureForecastModal() {
return;
}
refreshMarketScan();
}, 3000);
}, 30_000);
return () => {
cancelled = true;
@@ -346,6 +346,7 @@ type AssistantMessage = {
};
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
const HOME_OPPORTUNITY_REFRESH_MS = 30_000;
type AssistantDockPosition = {
right: number;
@@ -2395,6 +2396,7 @@ function DashboardScreen() {
const { t } = useI18n();
const didAutoFocusRef = useRef(false);
const marketScanInflightRef = useRef<Set<string>>(new Set());
const marketScanPollingRef = useRef(false);
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
Record<string, OpportunityScanState>
>({});
@@ -2517,7 +2519,7 @@ function DashboardScreen() {
const existingDetail = store.cityDetailsByName[cityName];
const marketScan =
existingDetail?.market_scan ||
(await store.ensureCityMarketScan(cityName, false));
(await store.ensureCityMarketScan(cityName, false, { lite: true }));
if (cancelled) return;
setMarketScanStatusByCity((current) => ({
...current,
@@ -2552,6 +2554,57 @@ function DashboardScreen() {
store.proAccess.loading,
]);
useEffect(() => {
if (!showHomepageChrome) return;
if (store.proAccess.loading || !store.proAccess.authenticated) return;
if (!marketScanTargetNames.length) return;
let cancelled = false;
let intervalId: number | null = null;
const refreshAllMarketScans = async () => {
if (cancelled || marketScanPollingRef.current) return;
marketScanPollingRef.current = true;
const queue = [...marketScanTargetNames];
try {
const runWorker = async () => {
while (!cancelled) {
const cityName = queue.shift();
if (!cityName) return;
await store.ensureCityMarketScan(cityName, false, { lite: true });
}
};
await Promise.allSettled(
Array.from({ length: Math.min(3, queue.length) }, () => runWorker()),
);
} finally {
marketScanPollingRef.current = false;
}
};
void refreshAllMarketScans();
intervalId = window.setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void refreshAllMarketScans();
}, HOME_OPPORTUNITY_REFRESH_MS);
return () => {
cancelled = true;
marketScanPollingRef.current = false;
if (intervalId != null) {
window.clearInterval(intervalId);
}
};
}, [
marketScanTargetNames,
showHomepageChrome,
store.ensureCityMarketScan,
store.proAccess.authenticated,
store.proAccess.loading,
]);
return (
<div
className={clsx(
+52 -3
View File
@@ -45,6 +45,11 @@ interface DashboardStoreValue extends DashboardState {
ensureCityMarketScan: (
cityName: string,
force?: boolean,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => Promise<CityDetail["market_scan"] | null>;
focusCity: (cityName: string) => Promise<void>;
forecastModalMode: ForecastModalMode | null;
@@ -298,6 +303,39 @@ function mergeCityDetail(
};
}
function mergeMarketScan(
current: CityDetail["market_scan"] | undefined,
incoming: CityDetail["market_scan"] | null | undefined,
): CityDetail["market_scan"] | undefined {
if (!incoming) return current;
if (!current) return incoming || undefined;
const preserveHeavySlices = incoming.scan_scope === "lite";
const nextTopBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.top_buckets) || incoming.top_buckets.length === 0)
? current.top_buckets
: incoming.top_buckets;
const nextAllBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.all_buckets) || incoming.all_buckets.length === 0)
? current.all_buckets
: incoming.all_buckets;
const nextRecentTrades =
preserveHeavySlices &&
(!Array.isArray(incoming.recent_trades) || incoming.recent_trades.length === 0)
? current.recent_trades
: incoming.recent_trades;
return {
...current,
...incoming,
top_buckets: nextTopBuckets,
all_buckets: nextAllBuckets,
recent_trades: nextRecentTrades,
};
}
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
const history = Array.isArray(payload.history) ? payload.history : [];
const previewCount = Number(payload.preview_count || history.length || 0);
@@ -569,7 +607,15 @@ export function DashboardStoreProvider({
return detail;
};
const ensureCityMarketScan = async (cityName: string, force = false) => {
const ensureCityMarketScan = async (
cityName: string,
force = false,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => {
let cached = cityDetailsByName[cityName];
try {
if (!cached) {
@@ -577,7 +623,10 @@ export function DashboardStoreProvider({
}
const payload = await dashboardClient.getCityMarketScan(cityName, {
force,
targetDate: cached?.local_date || selectedForecastDate || null,
lite: options?.lite === true,
marketSlug: options?.marketSlug || null,
targetDate:
options?.targetDate || cached?.local_date || selectedForecastDate || null,
});
if (!payload.market_scan) return null;
setCityDetailsByName((current) => {
@@ -587,7 +636,7 @@ export function DashboardStoreProvider({
...current,
[cityName]: {
...detail,
market_scan: payload.market_scan || undefined,
market_scan: mergeMarketScan(detail.market_scan, payload.market_scan),
},
};
});
+5
View File
@@ -303,6 +303,7 @@ export const dashboardClient = {
cityName: string,
options?: {
force?: boolean;
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
@@ -318,9 +319,13 @@ export const dashboardClient = {
if (options?.marketSlug) {
params.set("market_slug", options.marketSlug);
}
if (options?.lite) {
params.set("lite", "true");
}
const requestKey = [
cityName,
force ? "force" : "cached",
options?.lite ? "lite" : "full",
options?.targetDate || "",
options?.marketSlug || "",
].join("::");
+11
View File
@@ -369,23 +369,34 @@ export interface MarketScan {
temperature_bucket?: ProbabilityBucket | null;
model_probability?: number | null;
market_price?: number | null;
midpoint?: number | null;
spread?: number | null;
edge_percent?: number | null;
signal_label?: string | null;
confidence?: string | null;
probability_engine?: string | null;
probability_calibration_mode?: string | null;
yes_token?: MarketToken | null;
no_token?: MarketToken | null;
yes_buy?: number | null;
yes_sell?: number | null;
yes_midpoint?: number | null;
yes_spread?: number | null;
no_buy?: number | null;
no_sell?: number | null;
no_midpoint?: number | null;
no_spread?: number | null;
last_trade_price?: number | null;
liquidity?: number | null;
volume?: number | null;
quote_source?: string | null;
quote_age_ms?: number | null;
price_analysis?: MarketPriceAnalysis | null;
sparkline?: number[];
top_buckets?: MarketTopBucket[] | null;
all_buckets?: MarketTopBucket[] | null;
recent_trades?: unknown[];
scan_scope?: "lite" | "full" | string | null;
websocket?: Record<string, unknown>;
}
+11
View File
@@ -283,18 +283,28 @@ export interface MarketScan {
temperature_bucket: any | null;
model_probability: number | null;
market_price: number | null;
midpoint?: number | null;
spread?: number | null;
edge_percent: number | null;
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
confidence: "low" | "medium" | "high";
probability_engine?: string | null;
probability_calibration_mode?: string | null;
yes_token: MarketToken | null;
no_token: MarketToken | null;
yes_buy: number | null;
yes_sell: number | null;
yes_midpoint?: number | null;
yes_spread?: number | null;
no_buy: number | null;
no_sell: number | null;
no_midpoint?: number | null;
no_spread?: number | null;
last_trade_price: number | null;
liquidity: number | null;
volume: number | null;
quote_source?: string | null;
quote_age_ms?: number | null;
sparkline: number[];
top_buckets?: Array<{
label?: string | null;
@@ -311,6 +321,7 @@ export interface MarketScan {
is_primary?: boolean;
}>;
recent_trades: Trade[];
scan_scope?: "lite" | "full" | string | null;
websocket: any;
}