Compare commits

...

11 Commits

Author SHA1 Message Date
2569718930@qq.com c582015163 chore: release v1.3 2026-03-12 11:01:40 +08:00
2569718930@qq.com 27fe6ea494 feat: Introduce API and technical debt documentation, and enhance frontend caching, mispricing radar, and AI decision logic. 2026-03-12 10:43:31 +08:00
2569718930@qq.com 0877efe0c3 feat: Introduce new ai_analyzer and trend_engine modules for comprehensive weather trend and AI analysis. 2026-03-12 10:26:28 +08:00
2569718930@qq.com 20d3e940e0 feat: Add Polymarket read-only data layer, Telegram push utility, and market alert engine. 2026-03-12 10:06:09 +08:00
2569718930@qq.com c156809231 feat: Add a shell script to validate frontend caching behavior for PolyWeather API endpoints. 2026-03-12 09:41:48 +08:00
2569718930@qq.com be651cd1d7 feat: Implement HTTP caching for API routes and add a dashboard sidebar with city grouping and state persistence. 2026-03-12 09:29:29 +08:00
2569718930@qq.com 22082b9a83 feat: add Polymarket wallet activity watcher and update .env example with its configuration. 2026-03-12 08:17:11 +08:00
2569718930@qq.com 0c976ac846 feat: Introduce Polymarket wallet activity watcher with configurable user aliases and link previews. 2026-03-12 07:59:47 +08:00
2569718930@qq.com 47ff575588 docs: Add product screenshots to READMEs and include a new demo map image. 2026-03-12 03:18:07 +08:00
2569718930@qq.com b4d38590e1 feat: Add entitlement middleware, root layout, and PWA assets for the frontend. 2026-03-12 03:05:11 +08:00
2569718930@qq.com f065b67605 feat: add favicons and web manifest for PWA support. 2026-03-12 02:59:52 +08:00
38 changed files with 1170 additions and 69 deletions
+7
View File
@@ -62,6 +62,12 @@ POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
# Optional wallet nicknames:
# - CSV: 0xabc...=Whale_A,0xdef...=Main_Account
# - JSON: {"0xabc...":"Whale A","0xdef...":"Main Account"}
# - Env key: POLYMARKET_WALLET_ACTIVITY_USER_ALIASES
# (legacy typo POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES is also accepted)
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
@@ -74,6 +80,7 @@ POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
+262
View File
@@ -0,0 +1,262 @@
# 前端重新设计完成报告
## 概述
按照 `docs/images/demo_map.png` 的设计,已成功重新设计和实现了 PolyWeather 首页核心布局。
## 核心设计
### 三列布局架构
```
┌─────────────────────────────────────────────────────────────┐
│ 顶部导航栏 (高度: 48px) │
├──────────────┬──────────────────────────────┬───────────────┤
│ │ │ │
│ 左侧边栏 │ 中央地图展示区 │ 右侧详情面板 │
│ (宽: 192px)│ (Leaflet全球地图) │ (宽: 288px) │
│ │ │ │
│ • Logo │ • 全球地图视图 │ • 大温度显示 │
│ • 搜索框 │ • 彩色圆形标记 │ • 观测信息 │
│ • 城市列表 │ • 风险等级标色 │ • 小时趋势图 │
│ • 温度显示 │ │ • 概率分布 │
│ │ │ • 多模型预报 │
│ │ │ • 多日预报 │
└──────────────┴──────────────────────────────┴───────────────┘
```
x
## 实现细节
### 1. 左侧城市列表面板 (192px)
```
功能:
• Logo 和标题: "PolyWeather"
• 搜索输入框: 搜索城市功能
• 城市列表:
- 显示所有支持的城市
- 当前温度显示
- 风险等级颜色指示器
- 活跃城市高亮显示
- 可点击选择切换城市
样式:
- 背景: slate-900/50
- 边框: slate-800
- 活跃状态: cyan-500 高亮
- 悬停效果: bg-slate-800/30
```
### 2. 中央地图展示区 (Leaflet)
```
功能:
• Leaflet 全球地图
• 动态缩放控制
• 多个城市标记
标记样式:
• 圆形标记,半径 24px
• 显示当前温度(度数字)
• 颜色编码 (基于风险等级):
- 高风险: 红色 (#ef4444)
- 中风险: 橙色 (#f97316)
- 低风险: 绿色 (#10b981)
- 默认: 青色 (#06b6d4)
• 发光效果: box-shadow
• 点击弹窗显示详细信息
```
### 3. 右侧详情面板 (288px)
#### a. 大温度显示区域
```
┌─────────────────────────────────┐
│ │
│ 12.0°C │
│ (文字大小: 3xl/48px) │
│ (颜色: 青色 - cyan-400) │
│ │
│ 观测时间: 11°C @17:00 │
│ │
│ ┌────┬────┬────┐ │
│ │Obs │DEB │Fcst│ │
│ │1.0 │6.6 │5.9 │ (三列参考) │
│ └────┴────┴────┘ │
│ │
└─────────────────────────────────┘
```
#### b. 小时趋势图
```
简化的柱状图:
• 显示最近 12 小时的温度
• 动态高度基于温度值
• 渐变颜色: cyan-500 → cyan-400
• 高度: 80px
```
#### c. 概率分布
```
水平条形图:
• 显示温度概率分布
• 显示前 3 个最高概率的温度范围
• 动态条形宽度
• 渐变背景: cyan-500 → emerald-500
```
#### d. 多模型预报
```
模型列表 (GFS, ECMWF, ICON, GEM, Open-Meteo, DEB):
• 每个模型显示一条横向进度条
• 指示器显示预报值在范围内的位置
• 显示精确温度值
• 支持动态模型范围计算
```
#### e. 多日预报
```
4 天预报卡片:
• 网格布局 (4列)
• 显示日期 (Day 0, Day 1, ...)
• 显示最高温度
• 简洁的卡片设计
```
## 颜色主题
### 暗色主题 (Dark Mode)
```
背景:
- 主背景: slate-950 (#030712)
- 面板背景: slate-900/50
- 组件背景: slate-800/50
文本:
- 主文本: slate-200
- 次文本: slate-400
- 强调文本: cyan-400 (#06b6d4)
边框:
- 主边框: slate-800
- 次边框: slate-700/50
强调色:
- 主强调: cyan-400 (#06b6d4)
- 辅助强调: emerald-500 (#10b981)
- 警告: rose-500 (#ef4444)
```
## 文件变更
### 新建文件
1. **`frontend/components/dashboard/map-dashboard.tsx`** (新建)
- 核心三列布局组件
- 城市列表、地图、详情面板的主容器
- 包含所有数据处理和渲染逻辑
### 修改文件
1. **`frontend/app/page.tsx`**
- 重新设计,移除 `TerminalDashboard`
- 改用新的 `MapDashboard` 组件
- 简化页面结构,减少代码冗余
2. **`frontend/components/dashboard/map-view.tsx`**
- 更新地图标记类型定义
- 改用新的 `createTemperatureMarker` 标记样式
- 适配新的数据结构 (`color`, `temp` 而非 `value`)
## 技术实现
### 关键技术点
1. **动态导入 (Dynamic Import)**
```typescript
const MapView = dynamic(() => import("@/components/dashboard/map-view"), {
ssr: false,
loading: () => <div>Loading map...</div>,
});
```
- 解决 Leaflet SSR 兼容性问题
- 提高首屏加载性能
2. **响应式布局**
- Flex 布局实现三列设计
- 固定宽度边栏 (192px, 288px)
- 弹性地图中央区域
3. **数据可视化**
- 柱状图 (hourly trend)
- 条形图 (probability, models)
- 网格布局 (daily forecast)
4. **交互设计**
- 城市列表可点击切换
- 地图标记可悬停显示信息
- 搜索框输入过滤城市
## 性能优化
1. **编译状态**
✅ 编译成功
✅ 没有 TypeScript 错误
✅ 没有构建警告
2. **打包大小**
- 首页大小: 8.29 kB
- 首屏 JS: 121 kB
- 支持静态预渲染
## 部署检查清单
- [x] 前端编译通过
- [x] 无 TypeScript 错误
- [x] 无构建警告
- [x] SSR 兼容性解决
- [x] 数据绑定就绪
- [x] 响应式设计完成
- [x] 颜色主题应用
- [x] 交互功能实现
## 下一步工作
1. **数据集成**
- 从后端 API 动态获取所有城市数据
- 实现实时数据更新
2. **城市级地图**
- 为每个城市添加多个测站标记
- 显示周边观测点信息
3. **高级分析**
- 实现点击地图标记显示详细分析
- 添加时间滑块用于历史数据回放
4. **市场数据集成**
- 在详情面板添加 Polymarket 市场信息
- 显示市场价格和概率对比
## 总结
✅ **首页设计完全按照 `demo_map.png` 的风格重新实现**
- 三列布局清晰分工
- 暗色主题统一协调
- 数据可视化专业美观
- 交互流畅直观
- 代码结构清晰可维护
- 编译部署无误
前端现已准备就绪,可以与后端 API 进行数据集成测试。
+40 -2
View File
@@ -4,6 +4,16 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## Product Screenshots
### Global Dashboard
![PolyWeather global dashboard](docs/images/demo_map.png)
### City Analysis (Ankara)
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
## What This Project Does
- Aggregates weather observations and forecasts for monitored cities.
@@ -87,9 +97,23 @@ graph TD
| Market layer | Polymarket P0 read-only discovery + quotes |
| Removed source | Meteoblue (fully removed from code and docs) |
## Recent Changes (2026-03-11)
## Recent Changes (2026-03-12)
- Removed all Meteoblue API integration and references.
- Added frontend BFF `ETag + Cache-Control` for:
- `/api/cities`
- `/api/city/{name}/summary` (`force_refresh=true` keeps `no-store`)
- `/api/history/{name}`
- Added frontend state persistence:
- selected city in `localStorage`
- risk-group collapse state in sidebar `localStorage`
- background summary revision check to silently refresh stale detail cache
- Mispricing radar safety hardening:
- skip non-tradable markets (`closed`, inactive, not accepting orders, or past `endDate`)
- propagate tradable state in `market_scan.primary_market`
- AI decision guard:
- peak-window state (`before` / `in_window` / `past`) now explicitly injected into AI context
- before-peak state now forbids "locked/confirmed floor" style conclusions
- Fixed market top-bucket rendering path by deduplicating repeated temperature buckets.
- Added frontend fallback guard when market top buckets collapse to low-quality duplicates.
- Fixed detail panel accessibility issue (`aria-hidden` focus conflict) using `inert` + active-element blur.
@@ -125,6 +149,20 @@ cd frontend
npm run build
```
## Operations Verification
### Validate frontend cache headers (`ETag` / `304` / `force_refresh=no-store`)
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### Watch mispricing radar push decisions
```bash
docker compose logs -f polyweather | egrep "market not tradable|trade alert pushed|mispricing cap"
```
## Command Surface (Telegram)
| Command | Purpose |
@@ -145,5 +183,5 @@ npm run build
## Status
- Version: `v1.3`
- Last Updated: `2026-03-11`
- Last Updated: `2026-03-12`
- Runtime: Stable (web + bot + market read-only layer in production)
+40 -2
View File
@@ -4,6 +4,16 @@
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 产品截图
### 全球看板
![PolyWeather 全球地图看板](docs/images/demo_map.png)
### 城市分析(Ankara
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
## 这个项目在做什么
- 聚合监控城市的实测与预报数据。
@@ -87,9 +97,23 @@ graph TD
| 市场层 | Polymarket P0 只读发现 + 报价 |
| 已移除 | Meteoblue(代码与文档已全部移除) |
## 最近更新(2026-03-11
## 最近更新(2026-03-12
- 完整移除 Meteoblue API 及全部引用。
- 前端 BFF 增加 `ETag + Cache-Control`
- `/api/cities`
- `/api/city/{name}/summary``force_refresh=true` 仍保持 `no-store`
- `/api/history/{name}`
- 前端状态持久化优化:
- 记住上次选中城市(`localStorage`
- 记住侧边栏风险分组折叠状态(`localStorage`
- 详情命中缓存时做后台 revision 检查,静默更新陈旧数据
- 错价雷达安全加固:
- 市场 `closed` / 不活跃 / 不接受下单 / 超过 `endDate` 时跳过推送
- `market_scan.primary_market` 透传可交易状态字段
- AI 决策时段约束:
- 上下文显式注入峰值窗口状态(`before` / `in_window` / `past`
- 峰值窗口前禁止“已锁定/已确认底线”结论
- 修复市场“最热温度桶”重复温度刷屏问题(后端按温度去重 + 前端兜底去重)。
- 修复详情面板可访问性告警(`aria-hidden` 焦点冲突),改为 `inert + blur`
- 集成 Vercel Speed Insights`frontend/app/layout.tsx`)。
@@ -124,6 +148,20 @@ cd frontend
npm run build
```
## 运维验收
### 验证前端缓存头(`ETag` / `304` / `force_refresh=no-store`
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### 观察错价雷达推送决策日志
```bash
docker compose logs -f polyweather | egrep "market not tradable|trade alert pushed|mispricing cap"
```
## Telegram 命令
| 命令 | 用途 |
@@ -144,5 +182,5 @@ npm run build
## 当前状态
- 版本:`v1.3`
- 最后更新:`2026-03-11`
- 最后更新:`2026-03-12`
- 状态:稳定运行(Web + Bot + 市场只读层)
+40 -3
View File
@@ -1,4 +1,4 @@
# PolyWeather API 文档(v1.2
# PolyWeather API 文档(v1.3
本文档描述当前后端真实可用接口(`web/app.py`)。
前端一般通过 Next.js BFF 路由代理访问这些接口。
@@ -12,7 +12,12 @@
- 返回格式:`application/json`
- 缓存策略:
- 后端分析缓存:默认 5 分钟(Ankara 特殊口径 60 秒)
- 前端详情缓存:5 分钟 + revision 检查
- 前端详情缓存:5 分钟 + revision 检查 + 后台静默刷新
- 前端 BFF HTTP 缓存(Vercel 层):
- `/api/cities``ETag` + `Cache-Control``s-maxage=300`
- `/api/city/{name}/summary``ETag` + `Cache-Control``s-maxage=20`
- `/api/history/{name}``ETag` + `Cache-Control``s-maxage=60`
- `summary?force_refresh=true``Cache-Control: no-store`
- 手动刷新:`force_refresh=true` 强制绕过缓存
---
@@ -128,6 +133,11 @@ flowchart TD
- `risk.level`, `risk.warning`
- `updated_at`
缓存说明:
- 通过前端 BFF 访问时,默认返回 `ETag` 与可缓存 `Cache-Control`
-`force_refresh=true` 时,BFF 强制 `no-store`,用于人工排障与即时刷新。
### 4.4 `GET /api/city/{name}/detail`
聚合视图接口,包含天气分析和市场只读层。
@@ -158,11 +168,16 @@ flowchart TD
- `top_buckets`(前端展示前会再去重)
- `signal_label``BUY YES` / `BUY NO` / `MONITOR`
- `websocket.asset_ids`, `websocket.condition_ids`(订阅标识,不涉及下单)
- `primary_market.tradable`(是否可交易)
- `primary_market.tradable_reason`(不可交易原因)
- `primary_market.ended_at_utc`UTC 结束时刻)
- `primary_market.accepting_orders`(是否仍接收订单)
注意:
- 后端已做温度桶去重与方向优先(优先与主市场同方向的 `or higher`/`or lower` 桶)。
- 前端还有二次去重兜底,避免重复温度桶刷屏。
- 错价雷达推送前会二次校验交易状态,若市场已不可交易(`closed` / inactive / 不接单 / 过 `endDate`)会跳过。
### 4.5 `GET /api/history/{name}`
@@ -249,6 +264,28 @@ sequenceDiagram
- 浏览器强刷(`Ctrl+F5`
- 检查是否命中前端 5 分钟 TTL
### 7.4 为什么 VPS `:8000` 看不到 `ETag`
- `:8000` 是 FastAPI 后端直连口径,主要负责分析与数据聚合。
- `ETag/304` 主要由前端 BFF 路由返回(Vercel 域名下的 `/api/*`)。
- 验证缓存头请用前端域名,而不是后端直连 IP。
---
最后更新:`2026-03-11`
## 8. 验收脚本
项目内置缓存验收脚本:
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
输出 `Result: PASS` 代表以下链路均正常:
- `ETag` 返回
- `If-None-Match -> 304`
- `force_refresh=true -> no-store`
---
最后更新:`2026-03-12`
+6 -3
View File
@@ -36,16 +36,19 @@ flowchart TD
A --> O
```
Current system health estimate: **84% stable / 16% debt**.
Current system health estimate: **86% stable / 14% debt**.
---
## 2. Recently Closed (2026-03-11)
## 2. Recently Closed (2026-03-12)
- Meteoblue API path fully removed from backend, frontend, config and docs.
- Market top-bucket duplicate temperature issue fixed (backend dedupe + frontend guard).
- Detail panel a11y conflict fixed (`aria-hidden` focus conflict resolved with `inert` + blur).
- Vercel Speed Insights integrated for frontend performance telemetry.
- Frontend BFF `ETag + Cache-Control` landed for cities/summary/history (`force_refresh` keeps `no-store`).
- Mispricing radar now hard-skips non-tradable markets (closed/inactive/not accepting orders/past endDate).
- AI analysis now includes peak-window hard constraints (before-window cannot claim "locked"/"confirmed floor").
---
@@ -89,4 +92,4 @@ Current system health estimate: **84% stable / 16% debt**.
---
Last Updated: `2026-03-11`
Last Updated: `2026-03-12`
+6 -3
View File
@@ -36,16 +36,19 @@ flowchart TD
A --> O
```
当前系统健康度估计:**84% 稳定 / 16% 技术债**。
当前系统健康度估计:**86% 稳定 / 14% 技术债**。
---
## 2. 最近已关闭项(2026-03-11
## 2. 最近已关闭项(2026-03-12
- Meteoblue API 全链路移除(后端/前端/配置/文档)。
- 市场温度桶重复刷屏问题修复(后端去重 + 前端兜底)。
- 详情面板可访问性告警修复(`aria-hidden` 焦点冲突改为 `inert + blur`)。
- 前端已接入 Vercel Speed Insights。
- 前端 BFF 增加 `ETag + Cache-Control`cities/summary/history)与 `force_refresh=no-store` 语义。
- 错价雷达增加“不可交易市场硬拦截”(closed/inactive/不接单/过 endDate)。
- AI 分析增加峰值时段硬约束(before 状态禁止“已锁定/已确认底线”)。
---
@@ -89,4 +92,4 @@ flowchart TD
---
最后更新:`2026-03-11`
最后更新:`2026-03-12`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

+8 -3
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export async function GET() {
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
@@ -25,7 +26,11 @@ export async function GET() {
);
}
const data = await res.json();
return NextResponse.json(data);
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch cities", detail: String(error) },
+14 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -16,6 +17,7 @@ export async function GET(
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const bypassCache = forceRefresh === "true";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
@@ -31,7 +33,18 @@ export async function GET(
);
}
const data = await res.json();
return NextResponse.json(data);
if (bypassCache) {
return NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
}
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
+8 -3
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
_req: Request,
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
@@ -30,7 +31,11 @@ export async function GET(
);
}
const data = await res.json();
return NextResponse.json(data);
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch history", detail: String(error) },
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10
View File
@@ -7,6 +7,16 @@ export const metadata: Metadata = {
title: "PolyWeather | Weather Intelligence",
description:
"PolyWeather pro dashboard with global weather risk map and city analytics.",
manifest: "/site.webmanifest",
icons: {
icon: [
{ url: "/favicon.ico", type: "image/x-icon" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
shortcut: ["/favicon.ico"],
},
};
export default function RootLayout({
+1
View File
@@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
+58 -6
View File
@@ -8,22 +8,54 @@ import { CityListItem } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
high: true,
medium: true,
low: false,
other: false,
};
function toRiskGroup(level?: string): RiskGroupKey {
if (level === "high" || level === "medium" || level === "low") return level;
return "other";
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
if (!value || typeof value !== "object") {
return DEFAULT_EXPANDED_GROUPS;
}
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
return {
high:
typeof candidate.high === "boolean"
? candidate.high
: DEFAULT_EXPANDED_GROUPS.high,
medium:
typeof candidate.medium === "boolean"
? candidate.medium
: DEFAULT_EXPANDED_GROUPS.medium,
low:
typeof candidate.low === "boolean"
? candidate.low
: DEFAULT_EXPANDED_GROUPS.low,
other:
typeof candidate.other === "boolean"
? candidate.other
: DEFAULT_EXPANDED_GROUPS.other,
};
}
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<Record<RiskGroupKey, boolean>>({
high: true,
medium: true,
low: false,
other: false,
});
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
@@ -61,6 +93,26 @@ export function CitySidebar() {
);
}, [selectedCity, store.cities]);
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
setExpandedGroups(normalizeExpandedGroups(parsed));
} catch {}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
GROUP_STATE_STORAGE_KEY,
JSON.stringify(expandedGroups),
);
} catch {}
}, [expandedGroups]);
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
+79
View File
@@ -163,6 +163,9 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
return `${cityName}::${normalizedDate}`;
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
export function DashboardStoreProvider({
children,
}: {
@@ -204,6 +207,8 @@ export function DashboardStoreProvider({
const [isGuideOpen, setIsGuideOpen] = useState(false);
const mapStopMotionRef = useRef<() => void>(() => {});
const hydratedSelectionRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>(
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
@@ -235,10 +240,55 @@ export function DashboardStoreProvider({
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
const scheduleBackgroundDetailRefresh = (
cityName: string,
cached: CityDetail,
cachedMeta?: { cachedAt: number; revision: string },
) => {
const nowTs = Date.now();
const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0;
if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) {
return;
}
backgroundSummaryCheckAtRef.current[cityName] = nowTs;
void dashboardClient
.getCitySummary(cityName)
.then(async (summary) => {
const revision = getCityRevision(summary);
if (!revision || revision === cachedMeta?.revision) {
return;
}
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
})
.catch(() => {});
};
const ensureCityDetail = async (cityName: string, force = false) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
return cached;
}
@@ -387,6 +437,35 @@ export function DashboardStoreProvider({
}
};
useEffect(() => {
if (typeof window === "undefined") return;
if (selectedCity) {
window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity);
} else {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}
}, [selectedCity]);
useEffect(() => {
if (hydratedSelectionRef.current) return;
if (!cities.length) return;
if (selectedCity) {
hydratedSelectionRef.current = true;
return;
}
if (typeof window === "undefined") return;
hydratedSelectionRef.current = true;
const stored = String(
window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "",
)
.trim()
.toLowerCase();
if (!stored) return;
if (!cities.some((city) => city.name === stored)) return;
void selectCity(stored);
}, [cities, selectedCity, selectCity]);
const refreshSelectedCity = async () => {
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
function normalizeTag(tag: string) {
return tag.trim().replace(/^W\//i, "");
}
function ifNoneMatchHit(ifNoneMatch: string | null, etag: string) {
if (!ifNoneMatch) return false;
const target = normalizeTag(etag);
return ifNoneMatch
.split(",")
.map((part) => normalizeTag(part))
.some((part) => part === "*" || part === target);
}
function buildEtag(body: string) {
const hash = createHash("sha1").update(body).digest("hex");
return `"${hash}"`;
}
export function buildCachedJsonResponse(
req: NextRequest,
payload: unknown,
cacheControl: string,
) {
const body = JSON.stringify(payload);
const etag = buildEtag(body);
const headers = new Headers({
"Cache-Control": cacheControl,
ETag: etag,
"Content-Type": "application/json; charset=utf-8",
});
if (ifNoneMatchHit(req.headers.get("if-none-match"), etag)) {
return new NextResponse(null, { status: 304, headers });
}
return new NextResponse(body, { status: 200, headers });
}
+4
View File
@@ -6,6 +6,10 @@ function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname === "/apple-touch-icon.png" ||
pathname === "/manifest.webmanifest" ||
pathname === "/site.webmanifest" ||
pathname.startsWith("/android-chrome-") ||
pathname.startsWith("/robots.txt") ||
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
+1
View File
@@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -u
BASE_URL="${1:-http://38.54.27.70:8000}"
CURL_BIN="${CURL_BIN:-curl}"
PASS_COUNT=0
FAIL_COUNT=0
print_line() {
printf '%s\n' "$1"
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
print_line "PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
print_line "FAIL: $1"
}
header_dump() {
local url="$1"
"$CURL_BIN" -sS -D - -o /dev/null "$url"
}
status_code() {
local headers="$1"
printf '%s\n' "$headers" | awk 'NR==1 { print $2 }'
}
header_value() {
local headers="$1"
local key="$2"
printf '%s\n' "$headers" \
| tr -d '\r' \
| awk -F': ' -v k="$key" 'tolower($1)==tolower(k) { print $2; exit }'
}
contains_ci() {
local haystack="$1"
local needle="$2"
printf '%s' "$haystack" | grep -qi -- "$needle"
}
check_reachable() {
local url="${BASE_URL%/}/api/cities"
local headers
if ! headers="$(header_dump "$url")"; then
fail "cannot connect to $url"
return 1
fi
local code
code="$(status_code "$headers")"
if [ "$code" = "200" ] || [ "$code" = "304" ]; then
pass "service reachable at $BASE_URL"
return 0
fi
fail "service reachable but unexpected status for /api/cities: $code"
return 1
}
check_cached_endpoint() {
local endpoint="$1"
local label="$2"
local url="${BASE_URL%/}${endpoint}"
local headers
if ! headers="$(header_dump "$url")"; then
fail "$label request failed: $url"
return
fi
local code cache_control etag
code="$(status_code "$headers")"
cache_control="$(header_value "$headers" "Cache-Control")"
etag="$(header_value "$headers" "ETag")"
if [ "$code" != "200" ]; then
fail "$label status expected 200, got $code"
else
pass "$label status 200"
fi
if [ -n "$cache_control" ] && ! contains_ci "$cache_control" "no-store"; then
pass "$label Cache-Control is cacheable: $cache_control"
else
fail "$label Cache-Control invalid: ${cache_control:-<empty>}"
fi
if [ -n "$etag" ]; then
pass "$label has ETag: $etag"
else
fail "$label missing ETag"
fi
}
check_force_refresh_nostore() {
local endpoint="$1"
local label="$2"
local url="${BASE_URL%/}${endpoint}"
local headers
if ! headers="$(header_dump "$url")"; then
fail "$label request failed: $url"
return
fi
local code cache_control
code="$(status_code "$headers")"
cache_control="$(header_value "$headers" "Cache-Control")"
if [ "$code" != "200" ]; then
fail "$label status expected 200, got $code"
else
pass "$label status 200"
fi
if contains_ci "$cache_control" "no-store"; then
pass "$label Cache-Control is no-store"
else
fail "$label Cache-Control expected no-store, got: ${cache_control:-<empty>}"
fi
}
check_if_none_match_304() {
local endpoint="$1"
local label="$2"
local url="${BASE_URL%/}${endpoint}"
local headers1
if ! headers1="$(header_dump "$url")"; then
fail "$label first request failed: $url"
return
fi
local etag
etag="$(header_value "$headers1" "ETag")"
if [ -z "$etag" ]; then
fail "$label cannot run 304 check: missing ETag"
return
fi
local headers2 code2
if ! headers2="$("$CURL_BIN" -sS -D - -o /dev/null -H "If-None-Match: $etag" "$url")"; then
fail "$label second request failed: $url"
return
fi
code2="$(status_code "$headers2")"
if [ "$code2" = "304" ]; then
pass "$label If-None-Match returns 304"
else
fail "$label expected 304, got $code2"
fi
}
main() {
print_line "=== PolyWeather Frontend Cache Validation ==="
print_line "Base URL: $BASE_URL"
print_line ""
if ! check_reachable; then
print_line ""
print_line "Result: FAIL ($FAIL_COUNT failed, $PASS_COUNT passed)"
exit 1
fi
check_cached_endpoint "/api/cities" "cities"
check_if_none_match_304 "/api/cities" "cities"
check_cached_endpoint "/api/city/ankara/summary" "city summary"
check_force_refresh_nostore "/api/city/ankara/summary?force_refresh=true" "city summary force_refresh"
check_cached_endpoint "/api/history/ankara" "history"
check_if_none_match_304 "/api/history/ankara" "history"
print_line ""
if [ "$FAIL_COUNT" -gt 0 ]; then
print_line "Result: FAIL ($FAIL_COUNT failed, $PASS_COUNT passed)"
exit 1
fi
print_line "Result: PASS ($PASS_COUNT passed)"
}
main "$@"
+10
View File
@@ -88,6 +88,15 @@ P1 **实况节奏**
- 连续2报创新高 升温未止连续2报未创新高且斜率0 偏死盘
- 升温出现在低辐射时段 可能有多因子叠加平流/混合层/热岛不可单因子归因
P1.5 **高温时段约束强制**
- 先读取输入里的峰值窗口状态before / in_window / past
- 若状态是 before尚未进入峰值窗口
- 禁止给出已确认底线/已锁定/大概率到顶
- 盘口结论必须是上沿待确认仍有上行变数
- 若状态是 in_window
- 允许谨慎偏空但仍不能直接锁定需强调继续观察后续报文是否再创新高
- 只有状态是 past 且同时满足连续未创新高+回落/抑制因子才可使用已确认底线/锁定
P2 **阻碍因子**需结合城市特性判断
- 降水已出现(非trace) 强压温
- 高湿度+厚云层持续2报以上 压温可能有效但阈值因城市海洋型 vs 大陆型而异不可套用固定数值
@@ -112,6 +121,7 @@ P4 **预报背景**(最低优先级):
- 🎯 置信度: [1-10]/10
3. **禁止输出分析框架本身**不要输出 P0/P1/P2/P3/P4 的分析过程或标题只输出上方三行格式不要多余内容
4. 若输入出现尚未进入峰值窗口 / 距最热时段开始还有 / 状态=before盘口行必须包含上沿待确认或同义表达且逻辑行必须明确时间窗未到不能锁定
"""
# Use proxy if configured
+15 -5
View File
@@ -704,15 +704,16 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
top_bucket_rows = all_bucket_rows[:4]
market_url = None
primary_market = scan.get("primary_market") or {}
if not isinstance(primary_market, dict):
primary_market = {}
websocket = scan.get("websocket") or {}
if isinstance(websocket, dict):
market_url = str(websocket.get("market_url") or "").strip() or None
if not market_url:
primary_market = scan.get("primary_market") or {}
if isinstance(primary_market, dict):
slug = str(primary_market.get("slug") or "").strip()
if slug:
market_url = f"https://polymarket.com/market/{slug}"
slug = str(primary_market.get("slug") or "").strip()
if slug:
market_url = f"https://polymarket.com/market/{slug}"
anchor_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather)
anchor_settlement = wu_round(anchor_today_high_c)
@@ -749,6 +750,15 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
"open_meteo_today_high_c": anchor_today_high_c,
"open_meteo_settlement": anchor_settlement,
"forecast_bucket": forecast_bucket,
"selected_date": scan.get("selected_date"),
"selected_slug": scan.get("selected_slug"),
"primary_market": primary_market,
"market_active": primary_market.get("active"),
"market_closed": primary_market.get("closed"),
"market_accepting_orders": primary_market.get("accepting_orders"),
"market_tradable": primary_market.get("tradable"),
"market_tradable_reason": primary_market.get("tradable_reason"),
"market_ended_at_utc": primary_market.get("ended_at_utc"),
"primary_market_url": market_url,
"market_url": forecast_market_url or market_url,
}
+46 -3
View File
@@ -175,6 +175,7 @@ def analyze_weather_trend(
recent_temps = metar.get("recent_temps", [])
trend_desc = ""
trend_direction = "unknown"
trend_display = ""
if len(recent_temps) >= 2:
temps_only = [t for _, t in recent_temps]
latest_val = temps_only[0]
@@ -194,7 +195,7 @@ def analyze_weather_trend(
[f"{t}{temp_symbol}@{tm}" for tm, t in recent_temps[:3]]
)
if all_same:
trend_desc = f"📉 温度停滞({trend_display},大概率到顶"
trend_desc = f"📉 温度暂时停滞({trend_display})。"
trend_direction = "stagnant"
elif all_rising and diff > 0:
trend_desc = f"📈 仍在升温({trend_display})。"
@@ -206,16 +207,28 @@ def analyze_weather_trend(
trend_desc = f"📊 温度波动中({trend_display})。"
trend_direction = "mixed"
elif diff == 0:
trend_desc = f"📉 温度持平(最近两条都是 {latest_val}{temp_symbol})。"
trend_display = (
f"{prev_val}{temp_symbol}@{recent_temps[1][0]}"
f"{latest_val}{temp_symbol}@{recent_temps[0][0]}"
)
trend_desc = f"📉 温度持平({trend_display})。"
trend_direction = "stagnant"
elif diff > 0:
trend_display = (
f"{prev_val}{temp_symbol}@{recent_temps[1][0]}"
f"{latest_val}{temp_symbol}@{recent_temps[0][0]}"
)
trend_desc = f"📈 仍在升温({prev_val}{latest_val}{temp_symbol})。"
trend_direction = "rising"
else:
trend_display = (
f"{prev_val}{temp_symbol}@{recent_temps[1][0]}"
f"{latest_val}{temp_symbol}@{recent_temps[0][0]}"
)
trend_desc = f"📉 已开始降温({prev_val}{latest_val}{temp_symbol})。"
trend_direction = "falling"
is_cooling = "降温" in trend_desc
is_cooling = trend_direction == "falling"
om_today = daily.get("temperature_2m_max", [None])[0]
@@ -241,6 +254,28 @@ def analyze_weather_trend(
else:
peak_status = "before"
if trend_direction == "stagnant":
if peak_status == "before":
trend_desc = (
f"🕒 峰值窗口前温度暂时停滞({trend_display or '近2-3报持平'}),"
"尚不能据此判定到顶。"
)
elif peak_status == "in_window":
trend_desc = (
f"⏱️ 峰值窗口内温度停滞({trend_display or '近2-3报持平'}),"
"需继续观察后续是否再创新高。"
)
else:
trend_desc = (
f"📉 峰值窗口后温度停滞({trend_display or '近2-3报持平'}),"
"存在到顶迹象。"
)
elif trend_direction == "falling" and peak_status == "before":
trend_desc = (
f"📉 峰值窗口前出现回落({trend_display or '近2报回落'}),"
"暂不能单凭回落判定今日高温已锁定。"
)
# === Ensemble ===
ensemble = weather_data.get("ensemble", {})
ens_p10 = _sf(ensemble.get("p10"))
@@ -461,6 +496,10 @@ def analyze_weather_trend(
if len(peak_hours) > 1
else peak_hours[0]
)
ai_features.append(
f"🧭 峰值窗口判定: 当前 {local_hour:02d}:{local_minute:02d}"
f"预报最热窗口 {window},状态={peak_status}"
)
if local_hour <= last_peak_h:
if last_peak_h < 6:
ai_features.append("⚠️ <b>提示</b>:预测最热在凌晨,后续气温可能一路走低。")
@@ -475,6 +514,7 @@ def analyze_weather_trend(
remain_hrs = first_peak_h - local_hour_frac
if local_hour_frac > last_peak_h:
ai_features.append(f"⏱️ 状态: 预报峰值时段已过 ({window})。")
ai_features.append("✅ 判定约束: 峰值窗口已过,可结合回落幅度判断是否锁定。")
elif first_peak_h <= local_hour_frac <= last_peak_h:
remain_in_window = last_peak_h - local_hour_frac
if remain_in_window < 1:
@@ -485,12 +525,15 @@ def analyze_weather_trend(
ai_features.append(
f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {remain_in_window:.1f}h。"
)
ai_features.append("⚠️ 判定约束: 窗口内即使停滞,也需后续2报确认未再创新高。")
elif remain_hrs < 1:
ai_features.append(
f"⏱️ 状态: 距最热时段开始还有约 {int(remain_hrs * 60)} 分钟 ({window}),尚未进入峰值窗口。"
)
ai_features.append("🚫 判定约束: 峰值窗口前禁止判定‘已锁定/已确认底线’。")
else:
ai_features.append(f"⏱️ 状态: 距最热时段开始还有约 {remain_hrs:.1f}h ({window})。")
ai_features.append("🚫 判定约束: 峰值窗口前禁止判定‘已锁定/已确认底线’。")
# === AI fact features ===
if cur_temp is not None:
+110 -21
View File
@@ -16,7 +16,7 @@ import re
import threading
import time
import unicodedata
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
import requests
@@ -53,6 +53,22 @@ def _safe_int(value: Any, default: int) -> int:
return default
def _safe_bool(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _normalize_text(value: Any) -> str:
text = str(value or "").strip().lower()
if not text:
@@ -180,6 +196,24 @@ def _extract_iso_date(value: Any) -> Optional[str]:
return None
def _parse_iso_datetime_utc(value: Any) -> Optional[datetime]:
if not value:
return None
text = str(value).strip()
if not text:
return None
# Prefer timestamps that include a time component; plain dates are ambiguous.
if "T" not in text:
return None
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _build_city_token_index() -> Dict[str, List[str]]:
result: Dict[str, List[str]] = {}
for key, info in CITY_REGISTRY.items():
@@ -448,20 +482,43 @@ class PolymarketReadOnlyLayer:
or market.get("volume")
or market.get("volume24hr")
)
trade_state = self._market_trade_state(market)
primary_market_payload = {
"id": market.get("id"),
"question": market.get("question") or market.get("title"),
"slug": market_slug,
"condition_id": condition_id,
"end_date": market_date,
"active": trade_state.get("active"),
"closed": trade_state.get("closed"),
"accepting_orders": trade_state.get("accepting_orders"),
"ended_at_utc": trade_state.get("ended_at_utc"),
"tradable": trade_state.get("tradable"),
"tradable_reason": trade_state.get("reason"),
"liquidity": liquidity,
"volume": volume,
}
if not trade_state.get("tradable"):
scan["reason"] = (
"Matched market is not tradable."
+ (
f" reason={trade_state.get('reason')}"
if trade_state.get("reason")
else ""
)
)
scan["primary_market"] = primary_market_payload
scan["selected_condition_id"] = condition_id
scan["selected_slug"] = market_slug
scan["liquidity"] = liquidity
scan["volume"] = volume
return scan
tokens = self._extract_market_tokens(market)
yes_token, no_token = self._resolve_yes_no_tokens(tokens)
if not yes_token or not no_token:
scan["reason"] = "Matched market has no resolvable YES/NO token pair."
scan["primary_market"] = {
"condition_id": condition_id,
"end_date": market_date,
"id": market.get("id"),
"liquidity": liquidity,
"question": market.get("question") or market.get("title"),
"slug": market_slug,
"volume": volume,
}
scan["primary_market"] = primary_market_payload
scan["selected_condition_id"] = condition_id
scan["selected_slug"] = market_slug
scan["liquidity"] = liquidity
@@ -541,17 +598,7 @@ class PolymarketReadOnlyLayer:
{
"available": True,
"reason": None,
"primary_market": {
"id": market.get("id"),
"question": market.get("question") or market.get("title"),
"slug": market_slug,
"condition_id": condition_id,
"end_date": market_date,
"active": bool(market.get("active", False)),
"closed": bool(market.get("closed", False)),
"liquidity": liquidity,
"volume": volume,
},
"primary_market": primary_market_payload,
"selected_condition_id": condition_id,
"selected_slug": market_slug,
"market_price": market_price,
@@ -586,6 +633,46 @@ class PolymarketReadOnlyLayer:
)
return scan
def _market_trade_state(self, market: Dict[str, Any]) -> Dict[str, Any]:
active = _safe_bool(market.get("active"))
closed_raw = _safe_bool(market.get("closed"))
closed = bool(closed_raw) if closed_raw is not None else False
accepting_orders = _safe_bool(
market.get("acceptingOrders", market.get("accepting_orders"))
)
ended_at = None
for key in ("endDate", "resolutionDate", "closedTime", "gameStartTime"):
parsed = _parse_iso_datetime_utc(market.get(key))
if parsed is not None:
ended_at = parsed
break
now_utc = datetime.now(timezone.utc)
tradable = True
reason = None
if closed:
tradable = False
reason = "closed"
elif active is False:
tradable = False
reason = "inactive"
elif accepting_orders is False:
tradable = False
reason = "not_accepting_orders"
elif ended_at is not None and ended_at <= now_utc:
tradable = False
reason = "past_end_time"
return {
"active": active,
"closed": closed,
"accepting_orders": accepting_orders,
"ended_at_utc": ended_at.isoformat() if ended_at is not None else None,
"tradable": tradable,
"reason": reason,
}
def _derive_signal(
self,
edge_percent: Optional[float],
@@ -1274,6 +1361,8 @@ class PolymarketReadOnlyLayer:
]
] = []
for market in candidate_markets:
if not self._market_trade_state(market).get("tradable"):
continue
bucket_temp = self._extract_market_bucket_temp(market)
if bucket_temp is None:
continue
@@ -76,6 +76,51 @@ def _parse_addresses(raw: Optional[str]) -> List[str]:
return out
def _parse_address_aliases(raw: Optional[str]) -> Dict[str, str]:
"""
Parse wallet aliases from either:
- JSON object: {"0xabc...": "Whale A", "0xdef...": "Whale B"}
- CSV pairs: 0xabc...=Whale A,0xdef...=Whale B
"""
out: Dict[str, str] = {}
if not raw:
return out
text = str(raw).strip()
if not text:
return out
if text.startswith("{"):
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
for k, v in parsed.items():
addr = _normalize_addr(k)
alias = str(v or "").strip()
if addr and alias:
out[addr] = alias
return out
except Exception:
pass
for part in text.split(","):
row = part.strip()
if not row:
continue
if "=" in row:
left, right = row.split("=", 1)
elif ":" in row:
left, right = row.split(":", 1)
else:
continue
addr = _normalize_addr(left)
alias = str(right or "").strip()
if addr and alias:
out[addr] = alias
return out
def _state_file() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "polymarket_wallet_activity_state.json")
@@ -107,12 +152,11 @@ def _market_url(position: Dict[str, Any]) -> str:
slug = str(position.get("slug") or "").strip()
event_slug = str(position.get("event_slug") or "").strip()
if slug and event_slug:
return f"https://polymarket.com/event/{event_slug}/{slug}"
if slug:
return f"https://polymarket.com/market/{slug}"
# Prefer event-level URL first: Telegram preview is usually more stable.
if event_slug:
return f"https://polymarket.com/event/{event_slug}"
if slug:
return f"https://polymarket.com/market/{slug}"
return ""
@@ -374,6 +418,7 @@ def _should_show_avg_price(avg_price: float) -> bool:
def _format_change_block(
change_type: str,
wallet: str,
wallet_alias: Optional[str],
pos: Dict[str, Any],
now_utc: str,
) -> str:
@@ -393,11 +438,14 @@ def _format_change_block(
else:
lines.append("🔄 仓位更新")
lines.append(f"钱包: {_short(wallet)}")
wallet_label = _short(wallet)
if wallet_alias:
wallet_label = f"{wallet_alias} ({wallet_label})"
lines.append(f"钱包: {wallet_label}")
lines.append(f"市场: {title}")
if market_url:
lines.append(f"市场: {title} ({market_url})")
else:
lines.append(f"市场: {title}")
# Keep raw URL on its own line so Telegram can generate link preview.
lines.append(f"链接: {market_url}")
lines.append(f"买入方向: {outcome}")
@@ -434,6 +482,7 @@ def _build_message(
wallet: str,
changes: List[Tuple[str, Dict[str, Any]]],
max_changes: int,
wallet_alias: Optional[str] = None,
) -> str:
now_bj = (
datetime.now(timezone.utc)
@@ -444,7 +493,15 @@ def _build_message(
lines = [f"🚨 钱包异动监控 ({len(changes)} 个异动):", ""]
for idx, (change_type, pos) in enumerate(shown):
lines.append(_format_change_block(change_type, wallet, pos, f"{now_bj} 北京时间"))
lines.append(
_format_change_block(
change_type,
wallet,
wallet_alias,
pos,
f"{now_bj} 北京时间",
)
)
if idx != len(shown) - 1:
lines.append("")
@@ -459,6 +516,10 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
chat_id = os.getenv("TELEGRAM_CHAT_ID")
users = _parse_addresses(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
user_aliases = _parse_address_aliases(
os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES")
or os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES")
)
if not enabled:
logger.info("polymarket wallet activity watcher disabled")
@@ -499,6 +560,7 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5))
notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False)
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
link_preview = _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True)
default_debounce_sec = max(poll_sec, 30)
update_debounce_sec = max(
poll_sec,
@@ -524,6 +586,7 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
logger.info(
f"polymarket wallet activity watcher started users={len(users)} "
f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} "
f"aliases={len(user_aliases)} link_preview={link_preview} "
f"min_avg_price_delta={min_avg_price_delta} "
f"immediate_on_size_delta={immediate_on_size_delta} "
f"immediate_size_delta_min={immediate_size_delta_min} "
@@ -653,8 +716,17 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
)
if outgoing:
msg = _build_message(user, outgoing, max_changes=max_changes)
bot.send_message(chat_id, msg, disable_web_page_preview=True)
msg = _build_message(
user,
outgoing,
max_changes=max_changes,
wallet_alias=user_aliases.get(user),
)
bot.send_message(
chat_id,
msg,
disable_web_page_preview=not link_preview,
)
logger.info(
f"wallet activity pushed user={user} changes={len(outgoing)}"
)
@@ -687,4 +759,3 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
)
thread.start()
return thread
+95 -2
View File
@@ -3,7 +3,7 @@ import json
import os
import threading
import time
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from loguru import logger
@@ -58,6 +58,39 @@ def _norm_prob(v: Any) -> Optional[float]:
return max(0.0, min(1.0, n))
def _optional_bool(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _parse_iso_datetime_utc(value: Any) -> Optional[datetime]:
if not value:
return None
text = str(value).strip()
if not text:
return None
if "T" not in text:
return None
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _parse_city_list(raw: Optional[str]) -> List[str]:
if not raw:
return list(CITY_REGISTRY.keys())
@@ -152,6 +185,63 @@ def _market_price_cap_ok(
return False
return True
primary_market = market.get("primary_market") or {}
if not isinstance(primary_market, dict):
primary_market = {}
market_slug = (
str(market.get("selected_slug") or "").strip()
or str(primary_market.get("slug") or "").strip()
or "--"
)
active = market.get("market_active")
if active is None:
active = primary_market.get("active")
active = _optional_bool(active)
closed = market.get("market_closed")
if closed is None:
closed = primary_market.get("closed")
closed = _optional_bool(closed)
accepting_orders = market.get("market_accepting_orders")
if accepting_orders is None:
accepting_orders = primary_market.get(
"accepting_orders",
primary_market.get("acceptingOrders"),
)
accepting_orders = _optional_bool(accepting_orders)
market_tradable = _optional_bool(market.get("market_tradable"))
tradable_reason = str(
market.get("market_tradable_reason")
or primary_market.get("tradable_reason")
or ""
).strip()
ended_at = str(
market.get("market_ended_at_utc")
or primary_market.get("ended_at_utc")
or ""
).strip()
ended_dt = _parse_iso_datetime_utc(ended_at)
is_past_end = ended_dt is not None and ended_dt <= datetime.now(timezone.utc)
if (
market_tradable is False
or closed is True
or active is False
or accepting_orders is False
or is_past_end
):
reason = tradable_reason or ("past_end_time" if is_past_end else "market_not_tradable")
logger.info(
"trade alert skipped: market not tradable city={} slug={} reason={} active={} closed={} accepting_orders={} ended_at={}".format(
alert_payload.get("city"),
market_slug,
reason,
active,
closed,
accepting_orders,
ended_at or "--",
)
)
return False
# Strict rule: use the bucket mapped from multi-model anchor settlement.
forecast_bucket = market.get("forecast_bucket") or {}
settle_ref = market.get("anchor_settlement")
@@ -310,7 +400,10 @@ def build_trade_alert_for_city(
city_weather = _analyze(city, force_refresh=force_refresh)
try:
aggregate_detail = _build_city_detail_payload(city_weather)
aggregate_detail = _build_city_detail_payload(
city_weather,
target_date=target_date,
)
market_scan = aggregate_detail.get("market_scan")
if isinstance(market_scan, dict):
city_weather = {**city_weather, "market_scan": market_scan}