feat: initialize landing page, payment logic, bot coordination, and configuration validation systems

This commit is contained in:
2569718930@qq.com
2026-05-26 02:11:27 +08:00
parent b68772f031
commit e852b9bc89
10 changed files with 111 additions and 750 deletions
+3 -3
View File
@@ -13,13 +13,13 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
# 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
+15 -15
View File
@@ -63,7 +63,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
passwordSignupPlaceholder: isEn
? "Set at least 6 characters"
: "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather journey" : "开启天气交易之旅",
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
signupSubmit: isEn ? "Create account now" : "立即创建账号",
loginHint: isEn
? "After signing in, your homepage will be personalized."
@@ -105,9 +105,9 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather-signal portfolios."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您看清天气信号脉络。",
trusted: isEn ? "Trusted by institutional traders" : "深受机构交易员信赖",
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const;
const onResetPassword = async () => {
@@ -253,45 +253,45 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
</Link>
<div className="space-y-6">
<h2 className="text-3xl font-black leading-[1.25] tracking-tight bg-gradient-to-r from-white via-slate-100 to-slate-300 bg-clip-text text-transparent">
<h2 className="text-3xl font-black leading-[1.25] tracking-tight text-white animate-fade-up [animation-delay:150ms] opacity-0">
{isEn ? (
<>
Weather intelligence and risk management{" "}
<span className="inline-block px-2.5 py-0.5 rounded bg-blue-600 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25">
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
simplified.
</span>
</>
) : (
<>
{" "}
<span className="inline-block px-2.5 py-0.5 rounded bg-blue-600 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25">
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
</span>
</>
)}
</h2>
<p className="text-sm leading-7 text-slate-400 max-w-md">
<p className="text-sm leading-7 text-slate-400 max-w-md animate-fade-up [animation-delay:300ms] opacity-0">
{copy.desc}
</p>
</div>
</div>
{/* High-Fidelity Mock Terminal Preview Widget */}
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full">
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full animate-fade-up [animation-delay:450ms] opacity-0">
<div className="bg-[#0b0f19]/80 backdrop-blur-xl rounded-2xl p-6">
{/* Terminal Top Window Controls */}
<div className="flex items-center gap-1.5 mb-5">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_TERMINAL_v1.7</span>
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_CONSOLE_v1.7</span>
</div>
<div className="flex items-center justify-between border-b border-white/5 pb-3.5 mb-4">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-300">
{isEn ? "Runway 02L Settlement" : "跑道 02L 官方结算"}
{isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
</span>
</div>
<span className="font-mono text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 tracking-wider">LIVE</span>
@@ -304,7 +304,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
<span className="font-mono text-base font-bold text-white tracking-tight">28.8°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "UMA Threshold" : "结算阈值"}</span>
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Target Threshold" : "监控阈值"}</span>
<span className="font-mono text-base font-bold text-rose-400 tracking-tight">30.0°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
@@ -312,8 +312,8 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
<span className="font-mono text-base font-bold text-blue-400 tracking-tight">88.5%</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Index Price" : "结算指数"}</span>
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">$9.20</span>
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Observed Peak" : "今日最高"}</span>
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">29.2°C</span>
</div>
</div>
@@ -335,7 +335,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
{/* Threshold Line (30.0°C) */}
<line x1="0" y1="40" x2="340" y2="40" stroke="#f43f5e" strokeWidth="1" strokeDasharray="3 3" opacity="0.8" />
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C UMA</text>
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C Target</text>
{/* Gradient Area under Forecast */}
<path
@@ -23,26 +23,26 @@ import {
} from "@/lib/supabase/client";
const RAW_MARKET_ROWS_EN = [
["New York", "91.8°F", "+2.4", "High", "Long Yes"],
["Austin", "103.1°F", "+1.1", "Medium", "Wait"],
["Seoul", "83.4°F", "-0.7", "Low", "No Trade"],
["Tokyo", "88.2°F", "+1.8", "High", "Long Yes"],
["London", "72.6°F", "-0.2", "Low", "Observe"],
["New York", "91.8°F", "+2.4", "High Anomaly", "Approve Temp"],
["Austin", "103.1°F", "+1.1", "Normal", "Observe"],
["Seoul", "83.4°F", "-0.7", "Low Anomaly", "Veto Anomaly"],
["Tokyo", "88.2°F", "+1.8", "High Anomaly", "Approve Temp"],
["London", "72.6°F", "-0.2", "Normal", "Observe"],
];
const RAW_MARKET_ROWS_ZH = [
["纽约", "91.8°F", "+2.4", "高", "买入多头"],
["奥斯汀", "103.1°F", "+1.1", "", "观望"],
["首尔", "83.4°F", "-0.7", "低", "无交易"],
["东京", "88.2°F", "+1.8", "高", "买入多头"],
["伦敦", "72.6°F", "-0.2", "", "观察"],
["纽约", "91.8°F", "+2.4", "高偏差", "符合高温"],
["奥斯汀", "103.1°F", "+1.1", "正常", "持续观察"],
["首尔", "83.4°F", "-0.7", "低偏差", "否决偏差"],
["东京", "88.2°F", "+1.8", "高偏差", "符合高温"],
["伦敦", "72.6°F", "-0.2", "正常", "持续观察"],
];
const COVERAGE_EN = [
"Live airport observations",
"DEB blend forecast",
"Signal-implied temperature",
"Intraday settlement windows",
"Model-implied distribution",
"Intraday observation windows",
"AI weather evidence",
"Paid Telegram alerts",
];
@@ -50,8 +50,8 @@ const COVERAGE_EN = [
const COVERAGE_ZH = [
"机场实况观测数据",
"DEB 智能融合预报",
"信号隐含温度定价",
"日内分段结算窗口",
"模型隐含分布预测",
"日内分段观测窗口",
"AI 气象证据链解读",
"付费电报实时通知",
];
@@ -60,8 +60,8 @@ const PRO_FEATURES_EN = [
"Real-time METAR observations & runway sensor data",
"Real-time METAR observations & alerts",
"DEB blend forecast model",
"Signal-implied temperature pricing",
"Intraday settlement windows & risk metrics",
"Model-implied distribution analysis",
"Intraday observation windows & deviation metrics",
"Paid Telegram alerts & Webhook API",
"24/7 priority professional support",
];
@@ -70,8 +70,8 @@ const PRO_FEATURES_ZH = [
"实时 METAR 机场实测与跑道传感器数据",
"实时 METAR 机场实测与预警",
"DEB 智能融合预测模型",
"信号隐含温度定价与估",
"日内结算窗口与风险度量指标",
"模型隐含分布预测与估",
"日内观测窗口与偏差度量指标",
"付费电报群通知与 API 接口推送",
"7×24小时专业技术与客服支持",
];
@@ -101,7 +101,7 @@ function InstitutionalLandingScreen() {
{
icon: Radar,
title: "Live Evidence",
body: "Airport observations and official station data are structured for settlement-aware decisions.",
body: "Airport observations and official station data are structured for deviation-aware decisions.",
},
{
icon: Gauge,
@@ -118,12 +118,12 @@ function InstitutionalLandingScreen() {
{
icon: Radar,
title: "实况证据",
body: "针对机场 METAR 与官方站点数据进行结构化整理,专为结算博弈与交割设计。",
body: "针对机场 METAR 与官方站点数据进行结构化整理,专为气象决策设计。",
},
{
icon: Gauge,
title: "决策工作流",
body: "城市决策卡片融合了气象预报、实况偏差、历史风险系数及目标阈值盘口。",
body: "城市决策卡片依赖了气象预报、实测气温、偏差系数及目标阈值条件。",
},
{
icon: ShieldCheck,
@@ -133,8 +133,8 @@ function InstitutionalLandingScreen() {
];
const modelLabels = isEn
? ["DEB Blend", "Live METAR", "Signal Implied"]
: ["DEB 融合预测", "METAR 机场实测", "信号隐含价格"];
? ["DEB Blend", "Live METAR", "Model Consensus"]
: ["DEB 融合预测", "METAR 机场实测", "模型多方共识"];
return (
<div className="min-h-screen bg-[#f4f7fb] text-slate-950">
@@ -214,35 +214,49 @@ function InstitutionalLandingScreen() {
<main>
<section className="mx-auto grid min-h-[calc(100vh-64px)] max-w-7xl items-center gap-10 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:px-8">
<div>
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-bold uppercase text-blue-700">
<LockKeyhole size={13} />
{isEn ? "Paid professional terminal" : "付费专业交易终端"}
<div className="animate-fade-in">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-blue-200/60 bg-blue-50/80 px-3.5 py-1.5 text-xs font-bold uppercase tracking-wide text-blue-700 shadow-sm backdrop-blur-sm animate-fade-up [animation-delay:150ms] opacity-0">
<LockKeyhole size={14} className="text-blue-600" />
{isEn ? "Paid professional dashboard" : "付费专业气象决策台"}
</div>
<h1 className="max-w-2xl text-4xl font-black leading-[1.05] tracking-normal text-slate-950 sm:text-5xl lg:text-6xl">
{isEn ? "Institutional weather signal intelligence for paid users." : "面向付费用户的机构级天气决策台"}
<h1 className="max-w-3xl text-4xl font-black leading-[1.1] tracking-tight text-slate-900 sm:text-5xl lg:text-[4rem] lg:leading-[1.05] animate-fade-up [animation-delay:300ms] opacity-0">
{isEn ? (
<>
Institutional weather intelligence for{" "}
<span className="inline-block text-transparent bg-clip-text bg-[linear-gradient(to_right,#2563eb,#8b5cf6,#2563eb)] bg-[length:200%_auto] animate-gradient">
professional teams.
</span>
</>
) : (
<>
{" "}
<span className="inline-block text-transparent bg-clip-text bg-[linear-gradient(to_right,#2563eb,#8b5cf6,#2563eb)] bg-[length:200%_auto] animate-gradient pb-2">
</span>
</>
)}
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg">
<p className="mt-6 max-w-2xl text-base leading-relaxed text-slate-500 sm:text-lg animate-fade-up [animation-delay:450ms] opacity-0">
{isEn
? "PolyWeather turns live METAR observations, DEB forecast blends, model probabilities, and signal settlement logic into one professional decision workspace."
: "PolyWeather 将 METAR 机场实测、DEB 智能融合预报、模型概率及信号结算逻辑整合于一体,打造气象风险管理专业决策环境。"}
? "PolyWeather turns live METAR observations, DEB forecast blends, model probabilities, and deviation verification logic into one professional decision workspace."
: "PolyWeather 将 METAR 机场实测、DEB 智能融合预报、模型概率及偏差校验逻辑整合于一体,打造气象风险管理专业决策环境。"}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<div className="mt-10 flex flex-col gap-4 sm:flex-row animate-fade-up [animation-delay:600ms] opacity-0">
<Link
href={authChecked && isAuthenticated ? "/terminal" : "/auth/login?next=%2Fterminal"}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-blue-700 bg-blue-600 px-5 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-blue-700"
className="group inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-blue-700 bg-blue-600 px-6 py-3 text-sm font-bold text-white shadow-lg shadow-blue-600/20 transition-all hover:bg-blue-700 hover:shadow-blue-600/30 hover:-translate-y-0.5"
>
{isEn ? "Enter product" : "进入产品决策台"}
<ArrowRight size={16} />
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
</Link>
<Link
href="/account"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-5 py-3 text-sm font-bold text-slate-800 shadow-sm transition hover:border-slate-400 hover:text-slate-950"
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-bold text-slate-800 shadow-sm transition-all hover:border-slate-400 hover:bg-slate-50 hover:text-slate-950 hover:-translate-y-0.5"
>
{isEn ? "Subscribe / Manage account" : "订阅服务 / 管理账户"}
</Link>
</div>
<p className="mt-4 text-xs font-medium text-slate-500">
<p className="mt-5 text-xs font-medium text-slate-400 animate-fade-up [animation-delay:750ms] opacity-0">
{isEn
? "No free product access. Subscription is required before the terminal opens."
: "无免费公开产品通道。在使用决策台前必须先登录并开通订阅。"}
@@ -253,19 +267,19 @@ function InstitutionalLandingScreen() {
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-bold">
<BarChart3 size={16} className="text-blue-700" />
{isEn ? "Weather Signals Dashboard" : "天气信号交易面板"}
{isEn ? "Weather Intelligence Console" : "气象决策分析台"}
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
{isEn ? "Live" : "实时数据"}
</div>
</div>
<div className="grid gap-3 p-3 lg:grid-cols-[1fr_0.85fr]">
<div className="rounded-xl border border-slate-200">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-3 py-2">
<strong className="text-sm">{isEn ? "Temperature Thresholds" : "温度天气阈值"}</strong>
<span className="text-xs font-semibold text-slate-500">
{isEn ? "Price / Edge / Signal" : "价格 / 偏差 / 信号"}
<div className="rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50/80 px-4 py-2.5">
<strong className="text-sm text-slate-800">{isEn ? "Temperature Metrics" : "温度决策指标"}</strong>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
{isEn ? "Observed / Deviation / Decision" : "实测 / 偏差 / 决策"}
</span>
</div>
<div className="divide-y divide-slate-100">
@@ -320,13 +334,13 @@ function InstitutionalLandingScreen() {
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-emerald-800">
<TrendingUp size={16} />
{isEn ? "Current Signal" : "当前交易信号"}
<TrendingUp size={16} className="animate-pulse" />
{isEn ? "Current Anomaly / Action" : "当前异常与决策"}
</div>
<p className="text-sm leading-6 text-emerald-900">
<p className="text-sm leading-relaxed text-emerald-900/90 font-medium">
{isEn
? "New York high-temperature signal shows a positive observation deviation with confirmed airport evidence."
: "纽约高温阈值信号出现显著的正向观测偏差,机场天气实况已验证确认。"}
? "New York high-temperature target shows a positive observation deviation with confirmed airport evidence."
: "纽约高温阈值监测出现显著的正向观测偏差,机场天气实况已验证确认。"}
</p>
</div>
</div>
@@ -355,8 +369,8 @@ function InstitutionalLandingScreen() {
<p className="text-xs font-bold uppercase text-blue-700">
{isEn ? "Data Coverage" : "数据覆盖范围"}
</p>
<h2 className="mt-2 text-3xl font-black">
{isEn ? "Everything weather-signal users need in one place." : "天气信号交易者所需的一切,在此集结。"}
<h2 className="mt-3 text-3xl font-black sm:text-4xl">
{isEn ? "Everything weather intelligence users need in one place." : "气象决策分析人员所需的一切,在此集结。"}
</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-600">
@@ -394,18 +408,18 @@ function InstitutionalLandingScreen() {
<div className="mx-auto mt-16 max-w-lg">
<div className="relative flex flex-col rounded-3xl border-2 border-blue-600 bg-white p-8 shadow-sm text-left">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-blue-600 px-4 py-1 text-xs font-bold uppercase tracking-wider text-white shadow-sm">
{isEn ? "Pro Terminal" : "专业终端"}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-1.5 text-xs font-bold uppercase tracking-widest text-white shadow-md">
{isEn ? "Pro Workspace" : "专业决策分析台"}
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
<h3 className="text-2xl font-black text-slate-900 tracking-tight">
PolyWeather Pro
</h3>
<p className="mt-2 text-sm text-slate-500 leading-relaxed">
<p className="mt-3 text-sm text-slate-500 leading-relaxed">
{isEn
? "Full access to the institutional weather-signal terminal. Live METAR, DEB forecasts, probability distribution, AI decision cards, and real-time alerts."
: "完整访问机构级天气信号终端。实时 METAR、DEB 预报、概率分布、AI 决策卡片、实时通知。"}
? "Full access to the institutional weather intelligence workspace. Live METAR, DEB forecasts, probability distribution, AI decision cards, and real-time alerts."
: "完整访问机构级天气决策分析台。实时 METAR、DEB 预报、概率分布、AI 决策卡片、实时通知。"}
</p>
<div className="mt-6 flex items-baseline">
<span className="text-5xl font-black tracking-tight text-slate-900">
+20
View File
@@ -77,6 +77,26 @@ const config: Config = {
"elevation-2": "var(--shadow-elevation-2)",
"elevation-3": "var(--shadow-elevation-3)",
},
keyframes: {
"fade-up": {
"0%": { opacity: "0", transform: "translateY(20px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
gradient: {
"0%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
"100%": { backgroundPosition: "0% 50%" },
},
},
animation: {
"fade-up": "fade-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"fade-in": "fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards",
gradient: "gradient 8s ease infinite",
},
},
},
plugins: [],
-84
View File
@@ -1,84 +0,0 @@
from __future__ import annotations
import argparse
import importlib
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, Optional
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def _safe_float(value: Optional[str]) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _bucket_from_temp(value: Optional[str]) -> Optional[Dict[str, Any]]:
temp = _safe_float(value)
if temp is None:
return None
return {
"value": temp,
"temp": temp,
"label": f"{temp:g}°C",
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Print a read-only Polymarket market-scan diagnostic payload.",
)
parser.add_argument("--city", required=True, help="City key or display name.")
parser.add_argument("--date", required=True, help="Target date, YYYY-MM-DD.")
parser.add_argument(
"--bucket-temp",
help="Optional model/probability bucket temperature in Celsius.",
)
parser.add_argument(
"--model-probability",
help="Optional model probability. Accepts 0-1 or 0-100.",
)
parser.add_argument(
"--market-slug",
help="Optional Polymarket event/market slug.",
)
parser.add_argument(
"--debug-log",
action="store_true",
help="Also enable POLYMARKET_MARKET_SCAN_DEBUG for this process.",
)
args = parser.parse_args()
if args.debug_log:
os.environ["POLYMARKET_MARKET_SCAN_DEBUG"] = "true"
model_probability = _safe_float(args.model_probability)
if model_probability is not None and model_probability > 1.0:
model_probability = model_probability / 100.0
module = importlib.import_module("src.data_collection.polymarket_readonly")
layer = module.PolymarketReadOnlyLayer()
scan = layer.build_market_scan(
city=args.city,
target_date=args.date,
temperature_bucket=_bucket_from_temp(args.bucket_temp),
model_probability=model_probability,
forced_market_slug=args.market_slug,
)
print(json.dumps(scan, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-31
View File
@@ -84,7 +84,6 @@ class StartupCoordinator:
def start_all(self) -> RuntimeStatus:
loops = [
self._start_airport_high_freq_loop(),
self._start_polygon_wallet_loop(),
self._start_weekly_reward_loop(),
self._start_payment_event_loop(),
self._start_payment_confirm_loop(),
@@ -188,36 +187,6 @@ class StartupCoordinator:
),
)
def _start_polygon_wallet_loop(self) -> LoopStatus:
enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
chat_ids = get_telegram_chat_ids_from_env()
rpc_url = str(os.getenv("POLYGON_RPC_URL") or "").strip()
wallets_count = _parse_csv_count(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES"))
poll = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8))
details = {
"poll_sec": poll,
"wallets_count": wallets_count,
"polymarket_only": _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True),
"chat_targets": len(chat_ids),
}
validation_error = None
if not chat_ids:
validation_error = "missing_TELEGRAM_CHAT_IDS"
elif not rpc_url:
validation_error = "missing_POLYGON_RPC_URL"
elif wallets_count == 0:
validation_error = "missing_POLYGON_WALLET_WATCH_ADDRESSES"
return self._start_with_validation(
key="polygon_wallet_watch",
label="Polygon 钱包监听",
configured_enabled=enabled,
details=details,
validation_error=validation_error,
starter=lambda: import_module(
"src.onchain.polygon_wallet_watcher"
).start_polygon_wallet_watch_loop(self.bot),
)
def _start_weekly_reward_loop(self) -> LoopStatus:
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
chat_ids = get_telegram_chat_ids_from_env()
-549
View File
@@ -1,549 +0,0 @@
import json
import os
import threading
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Set, Tuple
from loguru import logger
from web3 import Web3
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
ERC20_ABI = [
{
"constant": True,
"inputs": [],
"name": "symbol",
"outputs": [{"name": "", "type": "string"}],
"type": "function",
},
{
"constant": True,
"inputs": [],
"name": "decimals",
"outputs": [{"name": "", "type": "uint8"}],
"type": "function",
},
]
# Source: Polymarket official developer docs (Polygon contract addresses)
# https://docs.polymarket.com/developers/market-makers/setup
DEFAULT_POLYMARKET_CONTRACTS: Dict[str, str] = {
"USDC": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
"pUSD": "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb",
"CTF": "0x4d97dcd97ec945f40cf65f87097ace5ea0476045",
"CTF_EXCHANGE": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"NEG_RISK_CTF_EXCHANGE": "0xC5d563A36AE78145C45a50134d48A1215220f80a",
"NEG_RISK_ADAPTER": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
}
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _short(addr: str, left: int = 6, right: int = 4) -> str:
if not addr:
return "unknown"
if len(addr) <= left + right + 2:
return addr
return f"{addr[:left + 2]}...{addr[-right:]}"
def _state_file() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "polygon_wallet_watch_state.json")
def _load_state(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
return {"last_scanned_block": 0, "seen_tx": {}}
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
data.setdefault("last_scanned_block", 0)
data.setdefault("seen_tx", {})
return data
except Exception as exc:
logger.warning(f"failed to load polygon watch state: {exc}")
return {"last_scanned_block": 0, "seen_tx": {}}
def _save_state(path: str, state: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _cleanup_seen_tx(state: Dict[str, Any], now_ts: int, keep_sec: int) -> None:
seen = state.get("seen_tx", {})
if not isinstance(seen, dict):
state["seen_tx"] = {}
return
stale = [key for key, value in seen.items() if now_ts - int(value or 0) > keep_sec]
for tx_hash in stale:
seen.pop(tx_hash, None)
def _normalize_addr(value: Any) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if text.startswith("0x") and len(text) == 42:
return text
return ""
def _parse_addresses(raw: Optional[str]) -> Set[str]:
out: Set[str] = set()
if not raw:
return out
for part in raw.split(","):
addr = _normalize_addr(part)
if addr:
out.add(addr)
return out
def _parse_polymarket_contracts(raw: Optional[str]) -> Dict[str, str]:
"""
Parse env like:
- "0xabc...,0xdef..."
- "CTF:0xabc...,EXCHANGE:0xdef..."
"""
result: Dict[str, str] = {}
if not raw:
return result
for part in raw.split(","):
segment = str(part).strip()
if not segment:
continue
label = "CUSTOM_PM"
address_part = segment
if ":" in segment:
maybe_label, maybe_addr = segment.split(":", 1)
maybe_addr_n = _normalize_addr(maybe_addr)
if maybe_addr_n:
label = (maybe_label or "CUSTOM_PM").strip() or "CUSTOM_PM"
address_part = maybe_addr_n
addr = _normalize_addr(address_part)
if not addr:
continue
if addr not in result:
result[addr] = label
return result
def _build_polymarket_contract_map() -> Dict[str, str]:
include_defaults = _env_bool("POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS", True)
merged: Dict[str, str] = {}
if include_defaults:
for label, addr in DEFAULT_POLYMARKET_CONTRACTS.items():
normalized = _normalize_addr(addr)
if normalized:
merged[normalized] = label
custom = _parse_polymarket_contracts(os.getenv("POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS"))
for addr, label in custom.items():
merged[addr] = label
return merged
def _polygon_scan_tx_url(tx_hash: str) -> str:
base = os.getenv("POLYGON_WALLET_WATCH_TX_BASE") or "https://polygonscan.com/tx/"
return f"{base.rstrip('/')}/{tx_hash}"
def _polygon_scan_addr_url(address: str) -> str:
base = os.getenv("POLYGON_WALLET_WATCH_ADDR_BASE") or "https://polygonscan.com/address/"
return f"{base.rstrip('/')}/{address}"
def _format_matic(wei_value: int) -> str:
try:
matic = Decimal(wei_value) / Decimal(10**18)
except (InvalidOperation, ValueError):
return "0"
if matic == matic.to_integral_value():
return f"{int(matic)}"
return f"{matic.normalize():f}".rstrip("0").rstrip(".")
def _format_amount(amount: Decimal) -> str:
if amount == amount.to_integral_value():
return str(int(amount))
return f"{amount.normalize():f}".rstrip("0").rstrip(".")
def _safe_lower(value: Any) -> str:
if value is None:
return ""
return str(value).lower()
def _topic_to_addr(topic: Any) -> str:
try:
return _normalize_addr("0x" + topic.hex()[-40:])
except Exception:
return ""
def _get_token_meta(
w3: Web3,
token_addr: str,
token_meta_cache: Dict[str, Tuple[str, int]],
) -> Tuple[str, int]:
symbol, decimals = token_meta_cache.get(token_addr, ("ERC20", 18))
if token_addr in token_meta_cache:
return symbol, decimals
try:
token = w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=ERC20_ABI)
symbol_raw = token.functions.symbol().call()
decimals_raw = token.functions.decimals().call()
symbol = str(symbol_raw or "ERC20")
decimals = int(decimals_raw)
except Exception:
symbol = "ERC20"
decimals = 18
token_meta_cache[token_addr] = (symbol, decimals)
return symbol, decimals
def _extract_receipt_signals(
w3: Web3,
receipt: Any,
watch_set: Set[str],
pm_contracts: Dict[str, str],
token_meta_cache: Dict[str, Tuple[str, int]],
) -> Dict[str, Any]:
transfer_lines: List[str] = []
approval_lines: List[str] = []
touched_labels: Set[str] = set()
pm_hit = False
for log in receipt.logs or []:
try:
log_addr = _normalize_addr(log.address)
if log_addr in pm_contracts:
pm_hit = True
touched_labels.add(pm_contracts[log_addr])
topics = log.topics or []
if not topics:
continue
topic0 = topics[0].hex().lower()
if topic0 == TRANSFER_TOPIC and len(topics) >= 3:
from_addr = _topic_to_addr(topics[1])
to_addr = _topic_to_addr(topics[2])
if from_addr not in watch_set and to_addr not in watch_set:
continue
other_addr = to_addr if from_addr in watch_set else from_addr
other_label = pm_contracts.get(other_addr)
if other_label:
pm_hit = True
touched_labels.add(other_label)
symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache)
amount_int = int(log.data.hex(), 16) if log.data else 0
amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0)))
if from_addr in watch_set and to_addr in watch_set:
direction = "SELF"
elif to_addr in watch_set:
direction = "IN"
else:
direction = "OUT"
# Keep transfer line only when it is clearly Polymarket related.
if other_label or log_addr in pm_contracts:
target = other_label or pm_contracts.get(log_addr) or _short(other_addr)
transfer_lines.append(
f"- {direction} {symbol}: {_format_amount(amount)} (对手: {target})"
)
if topic0 == APPROVAL_TOPIC and len(topics) >= 3:
owner = _topic_to_addr(topics[1])
spender = _topic_to_addr(topics[2])
if owner not in watch_set:
continue
spender_label = pm_contracts.get(spender)
if not spender_label:
continue
pm_hit = True
touched_labels.add(spender_label)
symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache)
amount_int = int(log.data.hex(), 16) if log.data else 0
amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0)))
approval_lines.append(
f"- APPROVE {symbol}: {_format_amount(amount)} -> {spender_label}"
)
except Exception:
continue
return {
"pm_hit": pm_hit,
"transfer_lines": transfer_lines,
"approval_lines": approval_lines,
"touched_labels": sorted(touched_labels),
}
def _build_message(
tx: Any,
block_ts: int,
matched_wallet: str,
touched_labels: List[str],
transfer_lines: List[str],
approval_lines: List[str],
tx_to_label: Optional[str],
) -> str:
tx_hash = tx["hash"].hex()
from_addr = _safe_lower(tx.get("from"))
to_addr = _safe_lower(tx.get("to"))
if from_addr == matched_wallet and to_addr == matched_wallet:
direction = "SELF"
elif to_addr == matched_wallet:
direction = "IN"
elif from_addr == matched_wallet:
direction = "OUT"
else:
direction = "RELATED"
matic_value = int(tx.get("value", 0) or 0)
block_time = datetime.fromtimestamp(block_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
selector = str(tx.get("input") or "")[:10] if tx.get("input") else "0x"
lines = [
"⛓ Polymarket 钱包动作",
f"钱包: {_short(matched_wallet)}",
f"方向: {direction}",
f"MATIC: {_format_matic(matic_value)}",
f"区块: {tx.get('blockNumber')}",
f"时间: {block_time}",
f"方法选择器: {selector}",
]
if tx_to_label:
lines.append(f"直连合约: {tx_to_label}")
if touched_labels:
lines.append(f"相关合约: {', '.join(touched_labels)}")
if transfer_lines:
lines.append("Token 动作:")
lines.extend(transfer_lines[:6])
if approval_lines:
lines.append("授权动作:")
lines.extend(approval_lines[:4])
lines.append(f"Tx: {tx_hash}")
lines.append(f"交易链接: {_polygon_scan_tx_url(tx_hash)}")
lines.append(f"钱包链接: {_polygon_scan_addr_url(matched_wallet)}")
return "\n".join(lines)
def start_polygon_wallet_watch_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
chat_ids = get_telegram_chat_ids_from_env()
rpc_url = os.getenv("POLYGON_RPC_URL")
watch_set = _parse_addresses(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES"))
polymarket_only = _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True)
pm_contracts = _build_polymarket_contract_map()
if not enabled:
logger.info("polygon wallet watcher disabled")
return None
if not chat_ids:
logger.warning("polygon wallet watcher skipped: TELEGRAM_CHAT_IDS is not set")
return None
if not rpc_url:
logger.warning("polygon wallet watcher skipped: POLYGON_RPC_URL is not set")
return None
if not watch_set:
logger.warning("polygon wallet watcher skipped: POLYGON_WALLET_WATCH_ADDRESSES is empty")
return None
if polymarket_only and not pm_contracts:
logger.warning("polygon wallet watcher skipped: no polymarket contracts configured")
return None
poll_sec = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8))
confirmations = max(0, _env_int("POLYGON_WALLET_WATCH_CONFIRMATIONS", 2))
max_blocks_per_cycle = max(1, _env_int("POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE", 30))
seen_ttl_sec = max(3600, _env_int("POLYGON_WALLET_WATCH_SEEN_TTL_SEC", 7 * 86400))
state_path = _state_file()
provider_timeout = max(5, _env_int("POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC", 10))
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": provider_timeout}))
def _runner() -> None:
token_meta_cache: Dict[str, Tuple[str, int]] = {}
state = _load_state(state_path)
if not w3.is_connected():
logger.error("polygon wallet watcher failed: cannot connect to POLYGON_RPC_URL")
return
try:
chain_id = int(w3.eth.chain_id)
if chain_id != 137:
logger.warning(f"polygon wallet watcher connected to unexpected chain_id={chain_id}")
except Exception as exc:
logger.warning(f"polygon wallet watcher cannot read chain id: {exc}")
latest_block = int(w3.eth.block_number)
if int(state.get("last_scanned_block") or 0) <= 0:
state["last_scanned_block"] = max(0, latest_block - confirmations)
_save_state(state_path, state)
logger.info(
f"polygon wallet watcher started wallets={len(watch_set)} "
f"polymarket_only={polymarket_only} pm_contracts={len(pm_contracts)} "
f"poll={poll_sec}s confirmations={confirmations} chat_targets={len(chat_ids)} "
f"state_path={state_path}"
)
while True:
cycle_ts = int(time.time())
try:
_cleanup_seen_tx(state, cycle_ts, seen_ttl_sec)
latest = int(w3.eth.block_number)
safe_latest = latest - confirmations
last_scanned = int(state.get("last_scanned_block") or 0)
if safe_latest <= last_scanned:
time.sleep(poll_sec)
continue
from_block = last_scanned + 1
to_block = min(safe_latest, from_block + max_blocks_per_cycle - 1)
for block_num in range(from_block, to_block + 1):
block = w3.eth.get_block(block_num, full_transactions=True)
block_ts = int(block.get("timestamp") or cycle_ts)
for tx in block.transactions or []:
tx_hash = tx["hash"].hex().lower()
from_addr = _safe_lower(tx.get("from"))
to_addr = _safe_lower(tx.get("to"))
matched_wallet = ""
if from_addr in watch_set:
matched_wallet = from_addr
elif to_addr in watch_set:
matched_wallet = to_addr
if not matched_wallet:
continue
if tx_hash in state.get("seen_tx", {}):
continue
tx_to = _normalize_addr(tx.get("to"))
tx_to_label = pm_contracts.get(tx_to)
pm_hit = bool(tx_to_label)
transfer_lines: List[str] = []
approval_lines: List[str] = []
touched_labels: List[str] = [tx_to_label] if tx_to_label else []
try:
receipt = w3.eth.get_transaction_receipt(tx["hash"])
parsed = _extract_receipt_signals(
w3=w3,
receipt=receipt,
watch_set=watch_set,
pm_contracts=pm_contracts,
token_meta_cache=token_meta_cache,
)
pm_hit = pm_hit or bool(parsed.get("pm_hit"))
transfer_lines = parsed.get("transfer_lines") or []
approval_lines = parsed.get("approval_lines") or []
touched = parsed.get("touched_labels") or []
touched_labels = sorted(set(touched_labels + touched))
except Exception:
transfer_lines = []
approval_lines = []
if polymarket_only and not pm_hit:
continue
message = _build_message(
tx=tx,
block_ts=block_ts,
matched_wallet=matched_wallet,
touched_labels=touched_labels,
transfer_lines=transfer_lines,
approval_lines=approval_lines,
tx_to_label=tx_to_label,
)
sent_count = 0
for chat_id in chat_ids:
try:
bot.send_message(
chat_id,
message,
disable_web_page_preview=True,
)
sent_count += 1
except Exception as exc:
logger.warning(
"polygon wallet alert push failed wallet={} chat_id={} error={}",
matched_wallet,
chat_id,
exc,
)
if sent_count <= 0:
continue
state.setdefault("seen_tx", {})[tx_hash] = cycle_ts
logger.info(
f"polygon wallet alert pushed wallet={matched_wallet} "
f"tx={tx_hash} block={block_num} polymarket={pm_hit} chat_targets={sent_count}"
)
state["last_scanned_block"] = block_num
_save_state(state_path, state)
time.sleep(1)
except Exception:
logger.exception("polygon wallet watcher cycle failed")
time.sleep(poll_sec)
thread = threading.Thread(
target=_runner,
name="polygon-wallet-watcher",
daemon=True,
)
thread.start()
return thread
-3
View File
@@ -22,7 +22,6 @@ DEFAULT_POLYGON_CHAIN_ID = 137
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
DEFAULT_USDT_ADDRESS = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"
DEFAULT_PUSD_ADDRESS = "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb"
PAYMENT_CONTRACT_ABI = [
{
@@ -392,8 +391,6 @@ class PaymentContractCheckoutService:
return {"code": "usdc", "symbol": "USDC", "name": "Native USDC"}
if normalized == _normalize_address(DEFAULT_USDT_ADDRESS):
return {"code": "usdt", "symbol": "USDT", "name": "USDT"}
if normalized == _normalize_address(DEFAULT_PUSD_ADDRESS):
return {"code": "pusd", "symbol": "pUSD", "name": "Polymarket pUSD"}
if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS):
return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"}
short = f"{normalized[:6]}...{normalized[-4:]}"
-6
View File
@@ -71,7 +71,6 @@ def validate_runtime_env(
entitlement_guard = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False)
payment_enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False)
weekly_reward_enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False)
polygon_watch_enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
if component_key == "bot":
missing = _missing(["TELEGRAM_BOT_TOKEN"])
@@ -111,11 +110,6 @@ def validate_runtime_env(
"已启用支付,但未配置 POLYWEATHER_PAYMENT_RECEIVER_CONTRACT 或 POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON"
)
if polygon_watch_enabled:
if not _has("POLYGON_WALLET_WATCH_ADDRESSES"):
report.warnings.append(
"已启用 polygon watcher,但未配置 POLYGON_WALLET_WATCH_ADDRESSES"
)
if component_key == "web":
if auth_enabled and not auth_required:
+2 -2
View File
@@ -6,7 +6,6 @@ class DummyBot:
def test_startup_coordinator_respects_disable_flags(monkeypatch):
monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false")
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
coordinator = StartupCoordinator(
@@ -19,7 +18,8 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch):
runtime = coordinator.start_all()
loop_map = runtime.loop_map()
assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env"
assert "weekly_reward" in loop_map
assert "polygon_wallet_watch" not in loop_map
def test_render_runtime_status_html_contains_key_fields():