Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c582015163 | |||
| 27fe6ea494 | |||
| 0877efe0c3 | |||
| 20d3e940e0 | |||
| c156809231 | |||
| be651cd1d7 | |||
| 22082b9a83 | |||
| 0c976ac846 | |||
| 47ff575588 | |||
| b4d38590e1 | |||
| f065b67605 |
@@ -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
|
||||
|
||||
@@ -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 进行数据集成测试。
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
### City Analysis (Ankara)
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## 产品截图
|
||||
|
||||
### 全球看板
|
||||
|
||||

|
||||
|
||||
### 城市分析(Ankara)
|
||||
|
||||

|
||||
|
||||
## 这个项目在做什么
|
||||
|
||||
- 聚合监控城市的实测与预报数据。
|
||||
@@ -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 + 市场只读层)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
Before Width: | Height: | Size: 507 KiB After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 542 KiB |
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 826 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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({
|
||||
|
||||
@@ -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"}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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/") ||
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 542 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 826 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||