feat: initialize landing page, payment logic, bot coordination, and configuration validation systems
This commit is contained in:
+3
-3
@@ -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 登录
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:]}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user