feat: Implement PolyWeather application with a map-based frontend, Python web services, market alert engine, and supporting utilities.
This commit is contained in:
@@ -1,6 +1,3 @@
|
||||
# Polymarket API Credentials
|
||||
POLYMARKET_API_KEY=your_api_key_here
|
||||
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
|
||||
if (forceRefresh != null) {
|
||||
params.set("force_refresh", forceRefresh);
|
||||
}
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
const url = `${API_BASE}/api/polymarket/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch polymarket snapshot", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export default function HomePage() {
|
||||
<main className="h-screen w-screen overflow-hidden bg-black">
|
||||
<iframe
|
||||
title="PolyWeather Legacy Dashboard"
|
||||
src="/legacy/index.html?v=market-v1"
|
||||
src="/legacy/index.html?v=legacy-v2"
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -108,14 +108,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="market-section">
|
||||
<h3>Market Prices</h3>
|
||||
<div id="marketSummary" class="market-summary"></div>
|
||||
<div id="marketBook" class="market-book">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Multi-Model Comparison ── -->
|
||||
<section class="models-section">
|
||||
<h3>🔬 多模型预报</h3>
|
||||
@@ -222,4 +214,4 @@
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -255,85 +255,6 @@ async function fetchCityDetail(cityName, force = false) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function fetchCityMarket(cityName, targetDate, force = false) {
|
||||
const urlName = cityName.replace(/\s/g, "-");
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: String(force),
|
||||
});
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function hasMarketSnapshot(data, targetDate) {
|
||||
return Boolean(
|
||||
data?.polymarket &&
|
||||
data.polymarket.target_date === targetDate &&
|
||||
!data.polymarket.loading &&
|
||||
!data.polymarket.fetch_error &&
|
||||
Array.isArray(data.polymarket.markets),
|
||||
);
|
||||
}
|
||||
|
||||
async function hydrateCityMarketData(data, force = false) {
|
||||
if (!data?.name) return null;
|
||||
|
||||
const targetDate = data.local_date || null;
|
||||
if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
|
||||
return data.polymarket;
|
||||
}
|
||||
if (!force && hasMarketSnapshot(data, targetDate)) {
|
||||
return data.polymarket;
|
||||
}
|
||||
|
||||
const existingMarkets = Array.isArray(data.polymarket?.markets)
|
||||
? data.polymarket.markets
|
||||
: [];
|
||||
|
||||
data.polymarket = {
|
||||
...(data.polymarket || {}),
|
||||
target_date: targetDate,
|
||||
loading: true,
|
||||
fetch_error: null,
|
||||
markets: existingMarkets,
|
||||
};
|
||||
|
||||
if (selectedCity === data.name) {
|
||||
renderMarketPrices(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await fetchCityMarket(data.name, targetDate, force);
|
||||
data.polymarket = {
|
||||
...snapshot,
|
||||
loading: false,
|
||||
fetch_error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to load market for ${data.name}:`, e);
|
||||
data.polymarket = {
|
||||
...(data.polymarket || {}),
|
||||
target_date: targetDate,
|
||||
loading: false,
|
||||
fetch_error: e.message || "Unknown error",
|
||||
markets: existingMarkets,
|
||||
};
|
||||
}
|
||||
|
||||
cityDataCache[data.name] = data;
|
||||
saveCache();
|
||||
|
||||
if (selectedCity === data.name) {
|
||||
renderMarketPrices(data);
|
||||
}
|
||||
|
||||
return data.polymarket;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Nearby Map Stations Rendering
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
|
||||
const cachedData = cityDataCache[cityName];
|
||||
renderPanel(cachedData);
|
||||
renderNearbyStations(cachedData);
|
||||
hydrateCityMarketData(cachedData, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
|
||||
cityDataCache[cityName] = data;
|
||||
saveCache();
|
||||
renderPanel(data);
|
||||
hydrateCityMarketData(data, force);
|
||||
|
||||
// Render nearby stations and zoom camera (cinematic or bounds)
|
||||
renderNearbyStations(data);
|
||||
@@ -494,8 +413,6 @@ function renderPanel(data) {
|
||||
renderChart(data);
|
||||
// Probabilities
|
||||
renderProbabilities(data);
|
||||
// Market prices
|
||||
renderMarketPrices(data);
|
||||
// Multi-model & Forecast synchronization
|
||||
if (!selectedForecastDate) {
|
||||
selectedForecastDate = data.local_date;
|
||||
@@ -1008,154 +925,6 @@ function formatCents(price) {
|
||||
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
|
||||
}
|
||||
|
||||
function formatCompactUsd(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
if (n >= 1000) {
|
||||
const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
|
||||
return `$${compact}k`;
|
||||
}
|
||||
return `$${Math.round(n)}`;
|
||||
}
|
||||
|
||||
function formatMarketThreshold(market, fallbackUnit) {
|
||||
const threshold = Number(market?.threshold);
|
||||
if (!Number.isFinite(threshold)) {
|
||||
return market?.question || "Market";
|
||||
}
|
||||
|
||||
const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
|
||||
const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
|
||||
const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
|
||||
const unit = unitRaw === "F" ? "°F" : "°C";
|
||||
|
||||
if (market?.contract_type === "exceed") {
|
||||
return `${value}${unit}+`;
|
||||
}
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
function findOutcome(market, outcomeName) {
|
||||
const target = String(outcomeName || "").toLowerCase();
|
||||
return (market?.outcomes || []).find(
|
||||
(outcome) => String(outcome?.name || "").toLowerCase() === target,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarketPrices(data) {
|
||||
const summary = document.getElementById("marketSummary");
|
||||
const container = document.getElementById("marketBook");
|
||||
const snapshot = data.polymarket || {};
|
||||
const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
|
||||
const targetDate = snapshot.target_date || data.local_date || "--";
|
||||
|
||||
if (snapshot.loading && markets.length === 0) {
|
||||
summary.innerHTML =
|
||||
'<span class="market-muted">Loading current Polymarket markets...</span>';
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.fetch_error && markets.length === 0) {
|
||||
summary.innerHTML = `<span class="market-error">Market load failed: ${escapeHtml(snapshot.fetch_error)}</span>`;
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (markets.length === 0) {
|
||||
summary.innerHTML =
|
||||
'<span class="market-muted">No Polymarket markets for this date</span>';
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
markets.sort((a, b) => {
|
||||
const left = Number(a?.threshold);
|
||||
const right = Number(b?.threshold);
|
||||
const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
|
||||
const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
|
||||
return safeLeft - safeRight;
|
||||
});
|
||||
|
||||
const primaryUrl = markets.find((market) => market?.url)?.url;
|
||||
const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
|
||||
const updatedAt = Number.isFinite(updatedAtTs)
|
||||
? new Date(updatedAtTs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: null;
|
||||
|
||||
summary.innerHTML = `
|
||||
<div class="market-summary-main">
|
||||
<span>${escapeHtml(targetDate)} - ${markets.length} markets</span>
|
||||
<span>Buy = best ask</span>
|
||||
<span>Sell = best bid</span>
|
||||
${updatedAt ? `<span>Updated ${escapeHtml(updatedAt)}</span>` : ""}
|
||||
</div>
|
||||
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">Open Polymarket</a>` : ""}
|
||||
`;
|
||||
|
||||
container.innerHTML = markets
|
||||
.map((market) => {
|
||||
const yes = findOutcome(market, "yes") || {};
|
||||
const no = findOutcome(market, "no") || {};
|
||||
const label = formatMarketThreshold(
|
||||
market,
|
||||
String(data.temp_symbol || "").includes("F") ? "F" : "C",
|
||||
);
|
||||
const spread = Number(yes.spread);
|
||||
const meta = [
|
||||
`Volume ${formatCompactUsd(market.volume)}`,
|
||||
`Liquidity ${formatCompactUsd(market.liquidity)}`,
|
||||
Number.isFinite(spread) ? `Yes spread ${formatCents(spread)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="market-row">
|
||||
<div class="market-contract">
|
||||
<div class="market-threshold">${escapeHtml(label)}</div>
|
||||
<div class="market-contract-meta">${meta.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
|
||||
<div class="market-question">${escapeHtml(market.question || "")}</div>
|
||||
</div>
|
||||
<div class="market-side yes">
|
||||
<div class="market-side-header">
|
||||
<span class="market-side-label yes">YES</span>
|
||||
<span class="market-last">Last ${formatCents(yes.last_trade_price)}</span>
|
||||
</div>
|
||||
<div class="market-side-prices">
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Buy</div>
|
||||
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
|
||||
</div>
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Sell</div>
|
||||
<div class="market-price-value">${formatCents(yes.sell_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="market-side no">
|
||||
<div class="market-side-header">
|
||||
<span class="market-side-label no">NO</span>
|
||||
<span class="market-last">Last ${formatCents(no.last_trade_price)}</span>
|
||||
</div>
|
||||
<div class="market-side-prices">
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Buy</div>
|
||||
<div class="market-price-value">${formatCents(no.buy_price)}</div>
|
||||
</div>
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Sell</div>
|
||||
<div class="market-price-value">${formatCents(no.sell_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderModels(data) {
|
||||
const container = document.getElementById("modelBars");
|
||||
const targetDate = selectedForecastDate || data.local_date;
|
||||
|
||||
@@ -668,155 +668,6 @@ body {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* ── Market Section ── */
|
||||
.market-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-summary-main {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.market-summary a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.market-summary a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.market-book {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.market-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.market-contract {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-threshold {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.market-contract-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-question {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.market-side {
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.market-side.yes {
|
||||
border-color: rgba(34, 197, 94, 0.18);
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.market-side.no {
|
||||
border-color: rgba(248, 113, 113, 0.18);
|
||||
background: rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
|
||||
.market-side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.market-side-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.market-side-label.yes {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.market-side-label.no {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.market-last {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-side-prices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.market-price-chip {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.market-price-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.market-price-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.market-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ── Model Bars ── */
|
||||
.model-bars {
|
||||
display: flex;
|
||||
@@ -1237,9 +1088,6 @@ body {
|
||||
:root {
|
||||
--panel-width: 100%;
|
||||
}
|
||||
.market-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
+108
-234
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Rule-based weather/market alert engine for short-horizon Polymarket trading.
|
||||
Rule-based weather alert engine for short-horizon trading signals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -188,178 +187,6 @@ def _calc_forecast_breakthrough_alert(city_weather: Dict[str, Any], temp_symbol:
|
||||
}
|
||||
|
||||
|
||||
def _convert_temp(value: float, from_unit: Optional[str], temp_symbol: str) -> float:
|
||||
from_u = (from_unit or "").upper()
|
||||
to_f = "F" in (temp_symbol or "").upper()
|
||||
if from_u == "F" and not to_f:
|
||||
return (value - 32.0) * 5.0 / 9.0
|
||||
if from_u == "C" and to_f:
|
||||
return (value * 9.0 / 5.0) + 32.0
|
||||
return value
|
||||
|
||||
|
||||
def _extract_numbers(text: str) -> List[float]:
|
||||
out: List[float] = []
|
||||
for m in re.finditer(r"-?\d+(?:\.\d+)?", text or ""):
|
||||
try:
|
||||
out.append(float(m.group(0)))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _extract_market_strikes(
|
||||
market_snapshot: Dict[str, Any],
|
||||
temp_symbol: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for market in market_snapshot.get("markets", []) or []:
|
||||
m_question = market.get("question") or ""
|
||||
m_id = market.get("id")
|
||||
|
||||
threshold = _sf(market.get("threshold"))
|
||||
threshold_unit = market.get("threshold_unit")
|
||||
if threshold is not None:
|
||||
candidates.append(
|
||||
{
|
||||
"strike": _convert_temp(threshold, threshold_unit, temp_symbol),
|
||||
"source": "market_threshold",
|
||||
"market_id": m_id,
|
||||
"question": m_question,
|
||||
}
|
||||
)
|
||||
|
||||
for outcome in market.get("outcomes", []) or []:
|
||||
name = str(outcome.get("name") or "")
|
||||
name_l = name.lower()
|
||||
if name_l in ("yes", "no"):
|
||||
continue
|
||||
if not any(tok in name_l for tok in ("-", "to", "below", "under", "above", "over", "deg", "f", "c")):
|
||||
continue
|
||||
vals = [v for v in _extract_numbers(name) if -80 <= v <= 160]
|
||||
for v in vals:
|
||||
candidates.append(
|
||||
{
|
||||
"strike": _convert_temp(v, threshold_unit, temp_symbol),
|
||||
"source": "outcome_number",
|
||||
"market_id": m_id,
|
||||
"question": m_question,
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _find_market_by_id(markets: List[Dict[str, Any]], market_id: Any) -> Optional[Dict[str, Any]]:
|
||||
for m in markets:
|
||||
if str(m.get("id")) == str(market_id):
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _extract_market_prices(target_market: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
prices: Dict[str, Any] = {
|
||||
"question": None,
|
||||
"yes_buy": None,
|
||||
"yes_sell": None,
|
||||
"yes_last": None,
|
||||
"no_buy": None,
|
||||
"no_sell": None,
|
||||
"no_last": None,
|
||||
}
|
||||
if not target_market:
|
||||
return prices
|
||||
|
||||
prices["question"] = target_market.get("question")
|
||||
for oc in target_market.get("outcomes", []) or []:
|
||||
name = str(oc.get("name") or "").strip().lower()
|
||||
if name not in ("yes", "no"):
|
||||
continue
|
||||
prefix = "yes" if name == "yes" else "no"
|
||||
prices[f"{prefix}_buy"] = _sf(oc.get("buy_price"))
|
||||
prices[f"{prefix}_sell"] = _sf(oc.get("sell_price"))
|
||||
prices[f"{prefix}_last"] = _sf(oc.get("last_trade_price"))
|
||||
if prices[f"{prefix}_last"] is None:
|
||||
prices[f"{prefix}_last"] = _sf(oc.get("last_price"))
|
||||
return prices
|
||||
|
||||
|
||||
def _format_market_price(value: Optional[float]) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
return f"{value * 100:.0f}c"
|
||||
|
||||
|
||||
def _format_temp_display(value: Optional[float], temp_symbol: str) -> str:
|
||||
if value is None:
|
||||
return f"-{temp_symbol}"
|
||||
rounded = round(float(value), 1)
|
||||
if abs(rounded - round(rounded)) < 1e-9:
|
||||
return f"{int(round(rounded))}{temp_symbol}"
|
||||
return f"{rounded:.1f}{temp_symbol}"
|
||||
|
||||
|
||||
def _calc_kill_zone_alert(
|
||||
city_weather: Dict[str, Any],
|
||||
market_snapshot: Dict[str, Any],
|
||||
temp_symbol: str,
|
||||
) -> Dict[str, Any]:
|
||||
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
|
||||
if current_temp is None:
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "current temperature unavailable",
|
||||
}
|
||||
|
||||
candidates = _extract_market_strikes(market_snapshot, temp_symbol)
|
||||
if not candidates:
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "no market strike candidates found",
|
||||
}
|
||||
|
||||
nearest = min(candidates, key=lambda row: abs(current_temp - row["strike"]))
|
||||
strike = _sf(nearest.get("strike"))
|
||||
if strike is None:
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "failed to parse strike temperature",
|
||||
}
|
||||
|
||||
threshold = _to_unit_delta(0.3, temp_symbol)
|
||||
distance = abs(current_temp - strike)
|
||||
triggered = distance < threshold
|
||||
|
||||
markets = market_snapshot.get("markets", []) or []
|
||||
target_market = _find_market_by_id(markets, nearest.get("market_id"))
|
||||
market_prices = _extract_market_prices(target_market)
|
||||
|
||||
no_probability = None
|
||||
if target_market:
|
||||
yes_price = market_prices.get("yes_buy")
|
||||
no_price = market_prices.get("no_buy")
|
||||
if no_price is None and yes_price is not None:
|
||||
no_price = max(0.0, min(1.0, 1.0 - yes_price))
|
||||
no_probability = no_price
|
||||
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": triggered,
|
||||
"current_temp": round(current_temp, 2),
|
||||
"strike_price": round(strike, 2),
|
||||
"market_label": f"{_format_temp_display(strike, temp_symbol)} 档位",
|
||||
"distance": round(distance, 2),
|
||||
"threshold": round(threshold, 2),
|
||||
"market_id": nearest.get("market_id"),
|
||||
"question": nearest.get("question"),
|
||||
"strike_source": nearest.get("source"),
|
||||
"no_probability": round(no_probability, 4) if no_probability is not None else None,
|
||||
"market_prices": market_prices,
|
||||
}
|
||||
|
||||
|
||||
def _pick_leading_station(city: str, nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not nearby:
|
||||
return None
|
||||
@@ -546,12 +373,52 @@ def _calc_advection_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dic
|
||||
}
|
||||
|
||||
|
||||
def _calc_peak_passed_guard(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
|
||||
current = city_weather.get("current") or {}
|
||||
current_temp = _sf(current.get("temp"))
|
||||
max_so_far = _sf(current.get("max_so_far"))
|
||||
max_temp_time = current.get("max_temp_time")
|
||||
local_time = city_weather.get("local_time")
|
||||
|
||||
if current_temp is None or max_so_far is None:
|
||||
return {"suppressed": False, "reason": "missing current/max_so_far"}
|
||||
|
||||
local_min = _minute_of_day(local_time)
|
||||
peak_min = _minute_of_day(max_temp_time)
|
||||
if local_min is None or peak_min is None:
|
||||
return {"suppressed": False, "reason": "missing local_time/max_temp_time"}
|
||||
|
||||
# Do not suppress in the morning; many cities still make their daily high later.
|
||||
if local_min < (14 * 60 + 30):
|
||||
return {"suppressed": False, "reason": "too early in local day"}
|
||||
|
||||
if peak_min >= local_min:
|
||||
return {"suppressed": False, "reason": "peak has not passed yet"}
|
||||
|
||||
minutes_since_peak = local_min - peak_min
|
||||
rollback = max_so_far - current_temp
|
||||
rollback_threshold = _to_unit_delta(0.8, temp_symbol)
|
||||
cooled_off = rollback >= rollback_threshold
|
||||
suppressed = minutes_since_peak >= 45 and cooled_off
|
||||
|
||||
return {
|
||||
"suppressed": suppressed,
|
||||
"reason": "late-day peak already passed" if suppressed else "cool-off threshold not met",
|
||||
"current_temp": round(current_temp, 2),
|
||||
"max_so_far": round(max_so_far, 2),
|
||||
"max_temp_time": max_temp_time,
|
||||
"local_time": local_time,
|
||||
"minutes_since_peak": minutes_since_peak,
|
||||
"rollback": round(rollback, 2),
|
||||
"rollback_threshold": round(rollback_threshold, 2),
|
||||
}
|
||||
|
||||
|
||||
def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
|
||||
mapping = [
|
||||
("ankara_center_deb_hit", "Center达到DEB"),
|
||||
("momentum_spike", "动量突变"),
|
||||
("forecast_breakthrough", "预测突破"),
|
||||
("kill_zone", "临界触发"),
|
||||
("advection", "暖平流"),
|
||||
]
|
||||
parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")]
|
||||
@@ -561,13 +428,24 @@ def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
|
||||
def _build_advice_cn(
|
||||
rules: Dict[str, Dict[str, Any]],
|
||||
temp_symbol: str,
|
||||
suppression: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
if (suppression or {}).get("suppressed"):
|
||||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||||
rollback = _sf((suppression or {}).get("rollback"))
|
||||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||||
return (
|
||||
f"当地高温大概率已在 {max_temp_time} 前后兑现,"
|
||||
f"较日内高点 {max_so_far:.1f}{temp_symbol} 已回落 {rollback:.1f}{temp_symbol},暂停主动推送。"
|
||||
)
|
||||
return "当地高温大概率已经兑现,当前进入回落阶段,暂停主动推送。"
|
||||
|
||||
parts: List[str] = []
|
||||
center_deb = rules.get("ankara_center_deb_hit", {})
|
||||
advection = rules.get("advection", {})
|
||||
momentum = rules.get("momentum_spike", {})
|
||||
breakthrough = rules.get("forecast_breakthrough", {})
|
||||
kill_zone = rules.get("kill_zone", {})
|
||||
|
||||
if center_deb.get("triggered"):
|
||||
deb_prediction = _sf(center_deb.get("deb_prediction"))
|
||||
@@ -589,15 +467,8 @@ def _build_advice_cn(
|
||||
if breakthrough.get("triggered"):
|
||||
parts.append("实测已击穿主流模型上沿")
|
||||
|
||||
no_prob = _sf(kill_zone.get("no_probability"))
|
||||
strike = _sf(kill_zone.get("strike_price"))
|
||||
if kill_zone.get("triggered") and no_prob is not None and strike is not None:
|
||||
parts.append(f'{no_prob * 100:.0f}% 概率的 {strike:.1f}{temp_symbol} "No" 单需谨慎')
|
||||
elif kill_zone.get("triggered") and strike is not None:
|
||||
parts.append(f"接近 {strike:.1f}{temp_symbol} 结算阻力位,波动率可能激增")
|
||||
|
||||
if not parts:
|
||||
return "当前未触发高优先级异动,继续观察盘口与实测联动。"
|
||||
return "当前未触发高优先级天气异动,继续观察实测与模型联动。"
|
||||
return ",".join(parts) + "。"
|
||||
|
||||
|
||||
@@ -605,26 +476,26 @@ def _build_telegram_messages(
|
||||
city_weather: Dict[str, Any],
|
||||
rules: Dict[str, Dict[str, Any]],
|
||||
map_url: Optional[str],
|
||||
suppression: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, str]:
|
||||
temp_symbol = city_weather.get("temp_symbol", "°C")
|
||||
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
|
||||
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
|
||||
center_deb = rules.get("ankara_center_deb_hit", {})
|
||||
momentum = rules.get("momentum_spike", {})
|
||||
kill_zone = rules.get("kill_zone", {})
|
||||
advection = rules.get("advection", {})
|
||||
|
||||
if current_temp is None:
|
||||
return {"zh": "", "en": ""}
|
||||
|
||||
suppressed = bool((suppression or {}).get("suppressed"))
|
||||
has_active_trigger = any(rule.get("triggered") for rule in rules.values())
|
||||
types_cn = _join_trigger_types_cn(rules) or "盘口异动"
|
||||
if suppressed:
|
||||
types_cn = "高温已过(暂停推送)"
|
||||
else:
|
||||
types_cn = _join_trigger_types_cn(rules) or "天气状态快照"
|
||||
delta_temp = _sf(momentum.get("delta_temp"))
|
||||
delta_min = momentum.get("delta_minutes")
|
||||
strike = _sf(kill_zone.get("strike_price"))
|
||||
distance = _sf(kill_zone.get("distance"))
|
||||
market_label = str(kill_zone.get("market_label") or "").strip()
|
||||
market_prices = kill_zone.get("market_prices") or {}
|
||||
center_station = center_deb.get("center_station") or {}
|
||||
|
||||
dyn = f"实测 {current_temp:.1f}{temp_symbol}"
|
||||
@@ -632,13 +503,6 @@ def _build_telegram_messages(
|
||||
icon = "🚀" if delta_temp > 0 else ("🧊" if delta_temp < 0 else "➖")
|
||||
dyn += f" ({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {icon}"
|
||||
|
||||
strike_line = ""
|
||||
if strike is not None and distance is not None:
|
||||
if current_temp < strike:
|
||||
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:还差 {distance:.1f}{temp_symbol}"
|
||||
else:
|
||||
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:高出 {distance:.1f}{temp_symbol}"
|
||||
|
||||
lead_line = ""
|
||||
if advection.get("triggered"):
|
||||
st_name = ((advection.get("lead_station") or {}).get("name")) or "nearby station"
|
||||
@@ -662,20 +526,18 @@ def _build_telegram_messages(
|
||||
if lead_gap is not None:
|
||||
center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
|
||||
|
||||
price_line = ""
|
||||
if any(
|
||||
market_prices.get(key) is not None
|
||||
for key in ("yes_buy", "yes_sell", "no_buy", "no_sell")
|
||||
):
|
||||
price_prefix = f"盘口({market_label}):" if market_label else "盘口:"
|
||||
price_line = (
|
||||
price_prefix
|
||||
+
|
||||
f"Yes 买 {_format_market_price(market_prices.get('yes_buy'))} / 卖 {_format_market_price(market_prices.get('yes_sell'))} | "
|
||||
f"No 买 {_format_market_price(market_prices.get('no_buy'))} / 卖 {_format_market_price(market_prices.get('no_sell'))}"
|
||||
)
|
||||
peak_line = ""
|
||||
if suppressed:
|
||||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||||
rollback = _sf((suppression or {}).get("rollback"))
|
||||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||||
peak_line = (
|
||||
f"高温状态:日内高点 {max_so_far:.1f}{temp_symbol} @ {max_temp_time},"
|
||||
f"当前已回落 {rollback:.1f}{temp_symbol}"
|
||||
)
|
||||
|
||||
advice = _build_advice_cn(rules, temp_symbol)
|
||||
advice = _build_advice_cn(rules, temp_symbol, suppression=suppression)
|
||||
final_map = map_url or "https://polyweather-pro.vercel.app/"
|
||||
title_zh = "🚨 PolyWeather 异动预警" if has_active_trigger else "📍 PolyWeather 状态快照"
|
||||
title_en = "🚨 PolyWeather Alert" if has_active_trigger else "📍 PolyWeather Status"
|
||||
@@ -686,27 +548,25 @@ def _build_telegram_messages(
|
||||
f"类型:{types_cn}",
|
||||
f"动态:{dyn}",
|
||||
]
|
||||
if strike_line:
|
||||
lines_zh.append(strike_line)
|
||||
if center_deb_line:
|
||||
lines_zh.append(center_deb_line)
|
||||
if price_line:
|
||||
lines_zh.append(price_line)
|
||||
if peak_line:
|
||||
lines_zh.append(peak_line)
|
||||
if lead_line:
|
||||
lines_zh.append(lead_line)
|
||||
lines_zh.append(f"AI 建议:{advice}")
|
||||
lines_zh.append(f"点击查看实时地图:{final_map}")
|
||||
|
||||
type_en = []
|
||||
if rules.get("ankara_center_deb_hit", {}).get("triggered"):
|
||||
type_en.append("Center Reached DEB")
|
||||
if rules.get("momentum_spike", {}).get("triggered"):
|
||||
type_en.append("Momentum Spike")
|
||||
if rules.get("forecast_breakthrough", {}).get("triggered"):
|
||||
type_en.append("Forecast Breakthrough")
|
||||
if rules.get("kill_zone", {}).get("triggered"):
|
||||
type_en.append("Kill Zone")
|
||||
if rules.get("advection", {}).get("triggered"):
|
||||
type_en.append("Advection")
|
||||
type_en_str = " + ".join(type_en) or "Market anomaly"
|
||||
type_en_str = "Peak Passed (suppressed)" if suppressed else (" + ".join(type_en) or "Weather snapshot")
|
||||
|
||||
lines_en = [
|
||||
f"{title_en} [{city_name}]",
|
||||
@@ -714,8 +574,6 @@ def _build_telegram_messages(
|
||||
f"Type: {type_en_str}",
|
||||
f"Now: {current_temp:.1f}{temp_symbol}",
|
||||
]
|
||||
if strike is not None and distance is not None:
|
||||
lines_en.append(f"Distance to {strike:.1f}{temp_symbol} strike: {distance:.1f}{temp_symbol}")
|
||||
if center_deb_line:
|
||||
center_temp = _sf(center_station.get("temp"))
|
||||
deb_prediction = _sf(center_deb.get("deb_prediction"))
|
||||
@@ -723,14 +581,15 @@ def _build_telegram_messages(
|
||||
lines_en.append(
|
||||
f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}"
|
||||
)
|
||||
if price_line:
|
||||
price_label_en = f"Quotes ({market_label}): " if market_label else "Quotes: "
|
||||
lines_en.append(
|
||||
price_label_en
|
||||
+
|
||||
f"Yes buy {_format_market_price(market_prices.get('yes_buy'))} / sell {_format_market_price(market_prices.get('yes_sell'))} | "
|
||||
f"No buy {_format_market_price(market_prices.get('no_buy'))} / sell {_format_market_price(market_prices.get('no_sell'))}"
|
||||
)
|
||||
if peak_line:
|
||||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||||
rollback = _sf((suppression or {}).get("rollback"))
|
||||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||||
lines_en.append(
|
||||
f"Peak state: intraday high {max_so_far:.1f}{temp_symbol} at {max_temp_time}, "
|
||||
f"now off by {rollback:.1f}{temp_symbol}"
|
||||
)
|
||||
lines_en.append(f"Action: {advice}")
|
||||
lines_en.append(f"Map: {final_map}")
|
||||
|
||||
@@ -739,11 +598,10 @@ def _build_telegram_messages(
|
||||
|
||||
def build_trading_alerts(
|
||||
city_weather: Dict[str, Any],
|
||||
market_snapshot: Dict[str, Any],
|
||||
map_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build weather+market trading alerts for paid Telegram delivery and web usage.
|
||||
Build weather-driven trading alerts for paid Telegram delivery and web usage.
|
||||
"""
|
||||
temp_symbol = city_weather.get("temp_symbol", "°C")
|
||||
city = city_weather.get("name", "")
|
||||
@@ -753,7 +611,6 @@ def build_trading_alerts(
|
||||
"ankara_center_deb_hit": _calc_ankara_center_deb_alert(city_weather, temp_symbol),
|
||||
"momentum_spike": _calc_momentum_alert(city_weather, temp_symbol),
|
||||
"forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol),
|
||||
"kill_zone": _calc_kill_zone_alert(city_weather, market_snapshot, temp_symbol),
|
||||
"advection": _calc_advection_alert(city_weather, temp_symbol),
|
||||
}
|
||||
|
||||
@@ -765,15 +622,31 @@ def build_trading_alerts(
|
||||
for key, value in rules.items()
|
||||
if value.get("triggered")
|
||||
]
|
||||
force_push = any(alert.get("force_push") for alert in triggered)
|
||||
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none")
|
||||
if force_push and severity == "none":
|
||||
severity = "medium"
|
||||
suppression = _calc_peak_passed_guard(city_weather, temp_symbol)
|
||||
if suppression.get("suppressed") and triggered:
|
||||
suppression["raw_trigger_types"] = [alert.get("type") for alert in triggered if alert.get("type")]
|
||||
for alert in triggered:
|
||||
rule = rules.get(alert.get("type") or "")
|
||||
if not rule:
|
||||
continue
|
||||
rule["raw_triggered"] = True
|
||||
rule["triggered"] = False
|
||||
rule["suppressed"] = True
|
||||
rule["suppression_reason"] = suppression.get("reason")
|
||||
triggered = []
|
||||
force_push = False
|
||||
severity = "none"
|
||||
else:
|
||||
force_push = any(alert.get("force_push") for alert in triggered)
|
||||
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none")
|
||||
if force_push and severity == "none":
|
||||
severity = "medium"
|
||||
|
||||
telegram = _build_telegram_messages(
|
||||
city_weather=city_weather,
|
||||
rules=rules,
|
||||
map_url=map_url,
|
||||
suppression=suppression,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -783,6 +656,7 @@ def build_trading_alerts(
|
||||
"severity": severity,
|
||||
"trigger_count": len(triggered),
|
||||
"rules": rules,
|
||||
"suppression": suppression,
|
||||
"triggered_alerts": triggered,
|
||||
"telegram": telegram,
|
||||
}
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
"""
|
||||
Polymarket Weather Market Client
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Market discovery + orderbook snapshot + anomaly detection for weather markets.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
|
||||
CITY_KEYWORDS = {
|
||||
"ankara": ["ankara"],
|
||||
"london": ["london"],
|
||||
"paris": ["paris"],
|
||||
"seoul": ["seoul"],
|
||||
"toronto": ["toronto"],
|
||||
"buenos aires": ["buenos aires"],
|
||||
"wellington": ["wellington"],
|
||||
"new york": ["new york", "nyc", "new york city"],
|
||||
"chicago": ["chicago"],
|
||||
"dallas": ["dallas"],
|
||||
"miami": ["miami"],
|
||||
"atlanta": ["atlanta"],
|
||||
"seattle": ["seattle"],
|
||||
}
|
||||
|
||||
CACHE_TTL_MARKETS = 300
|
||||
CACHE_TTL_BOOKS = 20
|
||||
SNAPSHOT_RETENTION_SEC = 48 * 3600
|
||||
|
||||
_market_cache: Dict[str, Any] = {}
|
||||
_market_cache_ts: float = 0.0
|
||||
_book_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_book_cache_ts: Dict[str, float] = {}
|
||||
_prev_snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _build_session(proxy: Optional[str] = None) -> requests.Session:
|
||||
"""Build a requests session with optional explicit proxy."""
|
||||
session = requests.Session()
|
||||
# Disable implicit system/environment proxies for deterministic behavior.
|
||||
session.trust_env = False
|
||||
|
||||
if proxy:
|
||||
if not proxy.startswith("http"):
|
||||
proxy = f"http://{proxy}"
|
||||
session.proxies = {"http": proxy, "https": proxy}
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json_list(v: Any) -> List[Any]:
|
||||
"""Parse value into list. Gamma often returns JSON-encoded strings."""
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(s)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _parse_threshold_from_question(question: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract simple threshold contracts like: exceed 45°F/7°C."""
|
||||
m = re.search(r"exceed\s+([\d.]+)\s*[°掳]?\s*([FC])", question, re.IGNORECASE)
|
||||
if m:
|
||||
return {
|
||||
"threshold": float(m.group(1)),
|
||||
"unit": m.group(2).upper(),
|
||||
"type": "exceed",
|
||||
}
|
||||
|
||||
if re.search(r"highest\s+temperature", question, re.IGNORECASE):
|
||||
return {"type": "range"}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _match_city(text: str) -> Optional[str]:
|
||||
text_l = (text or "").lower()
|
||||
for city, aliases in CITY_KEYWORDS.items():
|
||||
if any(alias in text_l for alias in aliases):
|
||||
return city
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_from_question(text: str) -> Optional[str]:
|
||||
"""Extract date from market question, return YYYY-MM-DD."""
|
||||
m = re.search(r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", text, re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
month_map = {
|
||||
"january": 1,
|
||||
"february": 2,
|
||||
"march": 3,
|
||||
"april": 4,
|
||||
"may": 5,
|
||||
"june": 6,
|
||||
"july": 7,
|
||||
"august": 8,
|
||||
"september": 9,
|
||||
"october": 10,
|
||||
"november": 11,
|
||||
"december": 12,
|
||||
}
|
||||
|
||||
month = month_map.get(m.group(1).lower())
|
||||
if month is None:
|
||||
return None
|
||||
|
||||
day = int(m.group(2))
|
||||
year = int(m.group(3)) if m.group(3) else datetime.utcnow().year
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
def _parse_iso_date(dt: Optional[str]) -> Optional[str]:
|
||||
if not dt:
|
||||
return None
|
||||
try:
|
||||
return dt[:10]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sort_by_volume(markets: List[Dict[str, Any]]) -> None:
|
||||
markets.sort(key=lambda x: _safe_float(x.get("volume")) or 0.0, reverse=True)
|
||||
|
||||
|
||||
def _cleanup_old_snapshots(now_ts: float) -> None:
|
||||
stale = [
|
||||
token for token, rec in _prev_snapshots.items()
|
||||
if now_ts - (_safe_float(rec.get("ts")) or 0.0) > SNAPSHOT_RETENTION_SEC
|
||||
]
|
||||
for token in stale:
|
||||
_prev_snapshots.pop(token, None)
|
||||
|
||||
|
||||
def fetch_weather_markets(
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch active weather markets and normalize outcome/token metadata."""
|
||||
global _market_cache, _market_cache_ts
|
||||
|
||||
now_ts = time.time()
|
||||
if (
|
||||
not force_refresh
|
||||
and _market_cache
|
||||
and now_ts - _market_cache_ts < CACHE_TTL_MARKETS
|
||||
):
|
||||
return _market_cache.get("_all", [])
|
||||
|
||||
session = _build_session(proxy)
|
||||
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{GAMMA_API}/events",
|
||||
params={
|
||||
"tag": "weather",
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"limit": 200,
|
||||
},
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
events = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Polymarket fetch_weather_markets failed: {exc}")
|
||||
return _market_cache.get("_all", [])
|
||||
|
||||
all_markets: List[Dict[str, Any]] = []
|
||||
|
||||
for event in events:
|
||||
event_title = event.get("title", "")
|
||||
event_slug = event.get("slug", "")
|
||||
event_end_date = _parse_iso_date(event.get("endDate"))
|
||||
|
||||
for mkt in event.get("markets", []) or []:
|
||||
question = mkt.get("question") or event_title
|
||||
city = _match_city(question) or _match_city(event_title)
|
||||
if not city:
|
||||
continue
|
||||
|
||||
target_date = (
|
||||
_parse_date_from_question(question)
|
||||
or _parse_date_from_question(event_title)
|
||||
or _parse_iso_date(mkt.get("endDate"))
|
||||
or event_end_date
|
||||
)
|
||||
|
||||
parsed = _parse_threshold_from_question(question)
|
||||
outcomes = [str(x) for x in _parse_json_list(mkt.get("outcomes"))]
|
||||
outcome_prices = [
|
||||
_safe_float(x) for x in _parse_json_list(mkt.get("outcomePrices"))
|
||||
]
|
||||
token_ids = [str(x) for x in _parse_json_list(mkt.get("clobTokenIds"))]
|
||||
|
||||
outcome_rows: List[Dict[str, Any]] = []
|
||||
for idx, name in enumerate(outcomes):
|
||||
outcome_rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"token_id": token_ids[idx] if idx < len(token_ids) else None,
|
||||
"last_price": (
|
||||
outcome_prices[idx] if idx < len(outcome_prices) else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
yes_price = None
|
||||
no_price = None
|
||||
for row in outcome_rows:
|
||||
name_l = row["name"].strip().lower()
|
||||
if name_l == "yes":
|
||||
yes_price = row.get("last_price")
|
||||
elif name_l == "no":
|
||||
no_price = row.get("last_price")
|
||||
|
||||
all_markets.append(
|
||||
{
|
||||
"id": mkt.get("id"),
|
||||
"question": question,
|
||||
"city": city,
|
||||
"date": target_date,
|
||||
"threshold": parsed.get("threshold") if parsed else None,
|
||||
"threshold_unit": parsed.get("unit") if parsed else None,
|
||||
"contract_type": (
|
||||
parsed.get("type", "unknown") if parsed else "unknown"
|
||||
),
|
||||
"yes_price": yes_price,
|
||||
"no_price": no_price,
|
||||
"volume": _safe_float(mkt.get("volume")),
|
||||
"liquidity": _safe_float(mkt.get("liquidityNum") or mkt.get("liquidity")),
|
||||
"slug": mkt.get("slug", ""),
|
||||
"event_slug": event_slug,
|
||||
"url": f"https://polymarket.com/event/{event_slug}" if event_slug else None,
|
||||
"outcomes": outcome_rows,
|
||||
"enable_order_book": bool(mkt.get("enableOrderBook", True)),
|
||||
}
|
||||
)
|
||||
|
||||
by_city: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for m in all_markets:
|
||||
by_city.setdefault(m["city"], []).append(m)
|
||||
|
||||
for city in by_city:
|
||||
_sort_by_volume(by_city[city])
|
||||
|
||||
_sort_by_volume(all_markets)
|
||||
_market_cache = {"_all": all_markets, **by_city}
|
||||
_market_cache_ts = now_ts
|
||||
logger.info(f"Polymarket fetched {len(all_markets)} weather markets")
|
||||
return all_markets
|
||||
|
||||
|
||||
def get_city_markets(
|
||||
city: str,
|
||||
target_date: Optional[str] = None,
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get city markets, optionally filtered by YYYY-MM-DD target date."""
|
||||
if not _market_cache or force_refresh or (time.time() - _market_cache_ts >= CACHE_TTL_MARKETS):
|
||||
fetch_weather_markets(proxy=proxy, timeout=timeout, force_refresh=force_refresh)
|
||||
|
||||
rows = list(_market_cache.get(city, []))
|
||||
if target_date:
|
||||
rows = [m for m in rows if m.get("date") == target_date]
|
||||
|
||||
_sort_by_volume(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def _extract_best_prices(orderbook: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||
bids = orderbook.get("bids") or []
|
||||
asks = orderbook.get("asks") or []
|
||||
|
||||
best_bid_price = None
|
||||
best_bid_size = None
|
||||
best_ask_price = None
|
||||
best_ask_size = None
|
||||
|
||||
for level in bids:
|
||||
p = _safe_float(level.get("price"))
|
||||
if p is None:
|
||||
continue
|
||||
s = _safe_float(level.get("size"))
|
||||
if best_bid_price is None or p > best_bid_price:
|
||||
best_bid_price = p
|
||||
best_bid_size = s
|
||||
|
||||
for level in asks:
|
||||
p = _safe_float(level.get("price"))
|
||||
if p is None:
|
||||
continue
|
||||
s = _safe_float(level.get("size"))
|
||||
if best_ask_price is None or p < best_ask_price:
|
||||
best_ask_price = p
|
||||
best_ask_size = s
|
||||
|
||||
spread = None
|
||||
if best_bid_price is not None and best_ask_price is not None:
|
||||
spread = best_ask_price - best_bid_price
|
||||
|
||||
return {
|
||||
"best_bid": best_bid_price,
|
||||
"best_bid_size": best_bid_size,
|
||||
"best_ask": best_ask_price,
|
||||
"best_ask_size": best_ask_size,
|
||||
"spread": spread,
|
||||
"last_trade_price": _safe_float(orderbook.get("last_trade_price")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_order_books(
|
||||
token_ids: List[str],
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 12,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Fetch order books for token IDs (prefer POST /books, fallback GET /book)."""
|
||||
now_ts = time.time()
|
||||
session = _build_session(proxy)
|
||||
|
||||
# Deduplicate while keeping order
|
||||
seen = set()
|
||||
normalized: List[str] = []
|
||||
for token_id in token_ids:
|
||||
tid = str(token_id or "").strip()
|
||||
if not tid or tid in seen:
|
||||
continue
|
||||
seen.add(tid)
|
||||
normalized.append(tid)
|
||||
|
||||
books: Dict[str, Dict[str, Any]] = {}
|
||||
to_fetch: List[str] = []
|
||||
|
||||
for tid in normalized:
|
||||
cached_ok = (
|
||||
(not force_refresh)
|
||||
and (tid in _book_cache)
|
||||
and (now_ts - _book_cache_ts.get(tid, 0) < CACHE_TTL_BOOKS)
|
||||
)
|
||||
if cached_ok:
|
||||
books[tid] = _book_cache[tid]
|
||||
else:
|
||||
to_fetch.append(tid)
|
||||
|
||||
if to_fetch:
|
||||
try:
|
||||
payload = [{"token_id": tid} for tid in to_fetch]
|
||||
resp = session.post(
|
||||
f"{CLOB_API}/books",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
rows = resp.json() or []
|
||||
|
||||
for row in rows:
|
||||
tid = str(row.get("asset_id") or row.get("token_id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
books[tid] = row
|
||||
_book_cache[tid] = row
|
||||
_book_cache_ts[tid] = now_ts
|
||||
except Exception as exc:
|
||||
logger.warning(f"Polymarket POST /books failed, fallback to /book: {exc}")
|
||||
|
||||
# Fallback for missing tokens
|
||||
for tid in to_fetch:
|
||||
if tid in books:
|
||||
continue
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{CLOB_API}/book",
|
||||
params={"token_id": tid},
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
row = resp.json()
|
||||
books[tid] = row
|
||||
_book_cache[tid] = row
|
||||
_book_cache_ts[tid] = now_ts
|
||||
except Exception as exc:
|
||||
logger.debug(f"Polymarket GET /book failed token={tid}: {exc}")
|
||||
|
||||
return books
|
||||
|
||||
|
||||
def _detect_anomaly_flags(
|
||||
token_id: str,
|
||||
best_bid: Optional[float],
|
||||
best_ask: Optional[float],
|
||||
spread: Optional[float],
|
||||
last_trade_price: Optional[float],
|
||||
best_bid_size: Optional[float],
|
||||
best_ask_size: Optional[float],
|
||||
now_ts: float,
|
||||
) -> List[str]:
|
||||
flags: List[str] = []
|
||||
|
||||
if best_bid is None or best_ask is None:
|
||||
flags.append("one_sided_orderbook")
|
||||
|
||||
if spread is not None and spread >= 0.08:
|
||||
flags.append("wide_spread")
|
||||
|
||||
if (best_bid_size is not None and best_bid_size < 25) or (
|
||||
best_ask_size is not None and best_ask_size < 25
|
||||
):
|
||||
flags.append("thin_liquidity")
|
||||
|
||||
prev = _prev_snapshots.get(token_id)
|
||||
if prev:
|
||||
prev_bid = _safe_float(prev.get("best_bid"))
|
||||
prev_ask = _safe_float(prev.get("best_ask"))
|
||||
prev_trade = _safe_float(prev.get("last_trade_price"))
|
||||
prev_spread = _safe_float(prev.get("spread"))
|
||||
|
||||
if (
|
||||
best_bid is not None
|
||||
and prev_bid is not None
|
||||
and abs(best_bid - prev_bid) >= 0.06
|
||||
):
|
||||
flags.append("bid_price_jump")
|
||||
|
||||
if (
|
||||
best_ask is not None
|
||||
and prev_ask is not None
|
||||
and abs(best_ask - prev_ask) >= 0.06
|
||||
):
|
||||
flags.append("ask_price_jump")
|
||||
|
||||
if (
|
||||
last_trade_price is not None
|
||||
and prev_trade is not None
|
||||
and abs(last_trade_price - prev_trade) >= 0.06
|
||||
):
|
||||
flags.append("last_trade_jump")
|
||||
|
||||
if (
|
||||
spread is not None
|
||||
and prev_spread is not None
|
||||
and spread - prev_spread >= 0.05
|
||||
):
|
||||
flags.append("spread_widening")
|
||||
|
||||
_prev_snapshots[token_id] = {
|
||||
"ts": now_ts,
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
}
|
||||
|
||||
return flags
|
||||
|
||||
|
||||
def build_city_market_snapshot(
|
||||
city: str,
|
||||
target_date: Optional[str] = None,
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build city/date market snapshot with buy/sell prices and anomaly flags.
|
||||
|
||||
buy_price = best ask (what you pay to buy)
|
||||
sell_price = best bid (what you receive when selling)
|
||||
"""
|
||||
now_ts = time.time()
|
||||
_cleanup_old_snapshots(now_ts)
|
||||
|
||||
markets = get_city_markets(
|
||||
city=city,
|
||||
target_date=target_date,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
token_ids: List[str] = []
|
||||
for market in markets:
|
||||
for outcome in market.get("outcomes", []):
|
||||
tid = outcome.get("token_id")
|
||||
if tid:
|
||||
token_ids.append(str(tid))
|
||||
|
||||
books_by_token = fetch_order_books(
|
||||
token_ids,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
snapshot_markets: List[Dict[str, Any]] = []
|
||||
alerts: List[Dict[str, Any]] = []
|
||||
|
||||
for market in markets:
|
||||
market_outcomes: List[Dict[str, Any]] = []
|
||||
market_alerts: List[Dict[str, Any]] = []
|
||||
|
||||
for outcome in market.get("outcomes", []):
|
||||
token_id = outcome.get("token_id")
|
||||
orderbook = books_by_token.get(str(token_id), {}) if token_id else {}
|
||||
top = _extract_best_prices(orderbook)
|
||||
|
||||
buy_price = top["best_ask"]
|
||||
sell_price = top["best_bid"]
|
||||
spread = top["spread"]
|
||||
last_trade_price = top["last_trade_price"]
|
||||
|
||||
flags = _detect_anomaly_flags(
|
||||
token_id=str(token_id or ""),
|
||||
best_bid=top["best_bid"],
|
||||
best_ask=top["best_ask"],
|
||||
spread=spread,
|
||||
last_trade_price=last_trade_price,
|
||||
best_bid_size=top["best_bid_size"],
|
||||
best_ask_size=top["best_ask_size"],
|
||||
now_ts=now_ts,
|
||||
) if token_id else []
|
||||
|
||||
row = {
|
||||
"name": outcome.get("name"),
|
||||
"token_id": token_id,
|
||||
"last_price": outcome.get("last_price"),
|
||||
"buy_price": buy_price,
|
||||
"sell_price": sell_price,
|
||||
"buy_size": top["best_ask_size"],
|
||||
"sell_size": top["best_bid_size"],
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
"book_timestamp": orderbook.get("timestamp"),
|
||||
"anomaly_flags": flags,
|
||||
}
|
||||
market_outcomes.append(row)
|
||||
|
||||
if flags:
|
||||
market_alert = {
|
||||
"market_id": market.get("id"),
|
||||
"question": market.get("question"),
|
||||
"outcome": outcome.get("name"),
|
||||
"token_id": token_id,
|
||||
"flags": flags,
|
||||
"buy_price": buy_price,
|
||||
"sell_price": sell_price,
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
}
|
||||
market_alerts.append(market_alert)
|
||||
alerts.append(market_alert)
|
||||
|
||||
snapshot_markets.append(
|
||||
{
|
||||
"id": market.get("id"),
|
||||
"question": market.get("question"),
|
||||
"city": market.get("city"),
|
||||
"date": market.get("date"),
|
||||
"threshold": market.get("threshold"),
|
||||
"threshold_unit": market.get("threshold_unit"),
|
||||
"contract_type": market.get("contract_type"),
|
||||
"slug": market.get("slug"),
|
||||
"url": market.get("url"),
|
||||
"volume": market.get("volume"),
|
||||
"liquidity": market.get("liquidity"),
|
||||
"outcomes": market_outcomes,
|
||||
"market_alerts": market_alerts,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"city": city,
|
||||
"target_date": target_date,
|
||||
"updated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"summary": {
|
||||
"market_count": len(snapshot_markets),
|
||||
"outcome_count": sum(len(m.get("outcomes", [])) for m in snapshot_markets),
|
||||
"alert_count": len(alerts),
|
||||
},
|
||||
"markets": snapshot_markets,
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
def compute_divergence(
|
||||
city_markets: List[Dict[str, Any]],
|
||||
prob_distribution: List[Dict[str, Any]],
|
||||
temp_symbol: str = "°C",
|
||||
use_fahrenheit: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compare probability-engine output with Polymarket yes/no pricing."""
|
||||
signals: List[Dict[str, Any]] = []
|
||||
|
||||
for mkt in city_markets:
|
||||
if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None:
|
||||
continue
|
||||
|
||||
threshold = _safe_float(mkt.get("threshold"))
|
||||
market_prob = _safe_float(mkt.get("yes_price"))
|
||||
mkt_unit = mkt.get("threshold_unit", "F")
|
||||
if threshold is None or market_prob is None:
|
||||
continue
|
||||
|
||||
# Convert threshold to our unit scale
|
||||
if mkt_unit == "F" and not use_fahrenheit:
|
||||
threshold_v = (threshold - 32) * 5 / 9
|
||||
else:
|
||||
threshold_v = threshold
|
||||
|
||||
threshold_wu = round(threshold_v)
|
||||
our_exceed_prob = 0.0
|
||||
for p in prob_distribution:
|
||||
if (p.get("value") or 0) >= threshold_wu:
|
||||
our_exceed_prob += _safe_float(p.get("probability")) or 0.0
|
||||
|
||||
divergence = our_exceed_prob - market_prob
|
||||
|
||||
signal = "neutral"
|
||||
if abs(divergence) > 0.10:
|
||||
signal = "underpriced" if divergence > 0 else "overpriced"
|
||||
elif abs(divergence) > 0.05:
|
||||
signal = "slight_under" if divergence > 0 else "slight_over"
|
||||
|
||||
signals.append(
|
||||
{
|
||||
"question": mkt.get("question"),
|
||||
"threshold": threshold,
|
||||
"threshold_unit": mkt_unit,
|
||||
"our_prob": round(our_exceed_prob, 3),
|
||||
"market_prob": round(market_prob, 3),
|
||||
"divergence": round(divergence, 3),
|
||||
"signal": signal,
|
||||
"volume": mkt.get("volume"),
|
||||
"url": mkt.get("url"),
|
||||
}
|
||||
)
|
||||
|
||||
return signals
|
||||
|
||||
@@ -15,13 +15,6 @@ def load_config():
|
||||
return val
|
||||
|
||||
config = {
|
||||
"polymarket": {
|
||||
"api_key": get_env_or_none("POLYMARKET_API_KEY"),
|
||||
"secret_key": get_env_or_none("POLYMARKET_SECRET_KEY"),
|
||||
"passphrase": get_env_or_none("POLYMARKET_PASSPHRASE"),
|
||||
"wallet_address": get_env_or_none("POLYMARKET_WALLET_ADDRESS"),
|
||||
"proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
|
||||
},
|
||||
"weather": {
|
||||
"openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"),
|
||||
"wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"),
|
||||
|
||||
@@ -116,8 +116,8 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
|
||||
center_deb = rules.get("ankara_center_deb_hit") or {}
|
||||
momentum = rules.get("momentum_spike") or {}
|
||||
breakthrough = rules.get("forecast_breakthrough") or {}
|
||||
kill_zone = rules.get("kill_zone") or {}
|
||||
advection = rules.get("advection") or {}
|
||||
suppression = alert_payload.get("suppression") or {}
|
||||
|
||||
signature_payload = {
|
||||
"city": alert_payload.get("city"),
|
||||
@@ -134,10 +134,12 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
|
||||
"momentum_direction": momentum.get("direction"),
|
||||
"momentum_slope_30m": round(float(momentum.get("slope_30m") or 0.0), 1),
|
||||
"breakthrough_margin": round(float(breakthrough.get("margin") or 0.0), 1),
|
||||
"kill_zone_strike": round(float(kill_zone.get("strike_price") or 0.0), 1),
|
||||
"kill_zone_distance": round(float(kill_zone.get("distance") or 0.0), 1),
|
||||
"lead_station": (advection.get("lead_station") or {}).get("name"),
|
||||
"lead_delta": round(float(advection.get("lead_delta") or 0.0), 1),
|
||||
"suppressed": bool(suppression.get("suppressed")),
|
||||
"suppression_reason": suppression.get("reason"),
|
||||
"suppression_peak_time": suppression.get("max_temp_time"),
|
||||
"suppression_rollback": round(float(suppression.get("rollback") or 0.0), 1),
|
||||
}
|
||||
raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True)
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
@@ -151,27 +153,15 @@ def build_trade_alert_for_city(
|
||||
) -> Dict[str, Any]:
|
||||
from web.app import _analyze
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
|
||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||
resolved_target_date = target_date or city_weather.get("local_date")
|
||||
if resolved_target_date:
|
||||
datetime.strptime(resolved_target_date, "%Y-%m-%d")
|
||||
|
||||
proxy = (
|
||||
(config.get("polymarket", {}) or {}).get("proxy")
|
||||
or (config.get("app", {}) or {}).get("proxy")
|
||||
)
|
||||
market_snapshot = build_city_market_snapshot(
|
||||
city=city,
|
||||
target_date=resolved_target_date,
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||
alert_payload = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=market_snapshot,
|
||||
map_url=map_url,
|
||||
)
|
||||
alert_payload["target_date"] = resolved_target_date
|
||||
|
||||
@@ -51,46 +51,20 @@ def _sample_weather_payload():
|
||||
}
|
||||
|
||||
|
||||
def _sample_market_snapshot():
|
||||
return {
|
||||
"city": "ankara",
|
||||
"target_date": "2026-03-07",
|
||||
"markets": [
|
||||
{
|
||||
"id": "m1",
|
||||
"question": "Will temperature in Ankara exceed 11.5°C on March 7?",
|
||||
"threshold": 11.5,
|
||||
"threshold_unit": "C",
|
||||
"contract_type": "exceed",
|
||||
"outcomes": [
|
||||
{"name": "Yes", "buy_price": 0.73, "last_price": 0.72},
|
||||
{"name": "No", "buy_price": 0.27, "last_price": 0.28},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_trading_alerts_all_core_rules_trigger():
|
||||
out = build_trading_alerts(
|
||||
city_weather=_sample_weather_payload(),
|
||||
market_snapshot=_sample_market_snapshot(),
|
||||
map_url="https://example.com/map",
|
||||
)
|
||||
|
||||
assert out["trigger_count"] >= 3
|
||||
assert out["rules"]["momentum_spike"]["triggered"] is True
|
||||
assert out["rules"]["forecast_breakthrough"]["triggered"] is True
|
||||
assert out["rules"]["kill_zone"]["triggered"] is True
|
||||
assert out["rules"]["advection"]["triggered"] is True
|
||||
|
||||
msg = out["telegram"]["zh"]
|
||||
assert "PolyWeather 异动预警" in msg
|
||||
assert "动量突变" in msg
|
||||
assert "盘口:" in msg
|
||||
assert "Yes 买 73c / 卖 -" in msg
|
||||
assert "No 买 27c / 卖 -" in msg
|
||||
assert "No\" 单需谨慎" in msg
|
||||
assert "https://example.com/map" in msg
|
||||
|
||||
|
||||
@@ -100,7 +74,6 @@ def test_forecast_breakthrough_not_triggered_when_current_not_above_margin():
|
||||
|
||||
out = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=_sample_market_snapshot(),
|
||||
)
|
||||
assert out["rules"]["forecast_breakthrough"]["triggered"] is False
|
||||
|
||||
@@ -118,7 +91,6 @@ def test_ankara_center_hits_deb_triggers_force_push():
|
||||
|
||||
out = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot={"city": "ankara", "target_date": "2026-03-07", "markets": []},
|
||||
)
|
||||
|
||||
center_rule = out["rules"]["ankara_center_deb_hit"]
|
||||
@@ -126,3 +98,47 @@ def test_ankara_center_hits_deb_triggers_force_push():
|
||||
assert center_rule["force_push"] is True
|
||||
assert out["severity"] in ("medium", "high")
|
||||
assert "Center信号" in out["telegram"]["zh"]
|
||||
|
||||
|
||||
def test_peak_passed_guard_suppresses_late_day_cooldown_alerts():
|
||||
city_weather = {
|
||||
"name": "wellington",
|
||||
"display_name": "Wellington",
|
||||
"temp_symbol": "°C",
|
||||
"local_time": "16:40",
|
||||
"current": {
|
||||
"temp": 19.0,
|
||||
"max_so_far": 20.2,
|
||||
"max_temp_time": "15:20",
|
||||
"wind_dir": 220.0,
|
||||
"wind_speed_kt": 8.0,
|
||||
},
|
||||
"trend": {
|
||||
"recent": [
|
||||
{"time": "16:40", "temp": 19.0},
|
||||
{"time": "16:10", "temp": 20.0},
|
||||
{"time": "15:40", "temp": 20.5},
|
||||
]
|
||||
},
|
||||
"multi_model": {
|
||||
"MGM": 18.2,
|
||||
"GFS": 18.4,
|
||||
"ECMWF": 18.5,
|
||||
},
|
||||
"deb": {"prediction": 18.7},
|
||||
"metar_recent_obs": [
|
||||
{"time": "16:40", "wdir": 220},
|
||||
{"time": "16:10", "wdir": 210},
|
||||
],
|
||||
"mgm_nearby": [],
|
||||
}
|
||||
|
||||
out = build_trading_alerts(city_weather=city_weather)
|
||||
|
||||
assert out["suppression"]["suppressed"] is True
|
||||
assert out["severity"] == "none"
|
||||
assert out["trigger_count"] == 0
|
||||
assert out["rules"]["momentum_spike"]["raw_triggered"] is True
|
||||
assert out["rules"]["forecast_breakthrough"]["raw_triggered"] is True
|
||||
assert "高温已过(暂停推送)" in out["telegram"]["zh"]
|
||||
assert "暂停主动推送" in out["telegram"]["zh"]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
from src.data_collection import polymarket_client as pm
|
||||
|
||||
|
||||
def test_extract_best_prices():
|
||||
book = {
|
||||
"bids": [{"price": "0.41", "size": "100"}, {"price": "0.39", "size": "80"}],
|
||||
"asks": [{"price": "0.45", "size": "90"}, {"price": "0.47", "size": "70"}],
|
||||
"last_trade_price": "0.44",
|
||||
}
|
||||
out = pm._extract_best_prices(book)
|
||||
assert out["best_bid"] == 0.41
|
||||
assert out["best_ask"] == 0.45
|
||||
assert out["spread"] == 0.04
|
||||
assert out["last_trade_price"] == 0.44
|
||||
|
||||
|
||||
def test_build_city_market_snapshot_buy_sell_and_alerts(monkeypatch):
|
||||
pm._prev_snapshots.clear()
|
||||
|
||||
markets = [
|
||||
{
|
||||
"id": "m1",
|
||||
"question": "Highest temperature in Ankara on March 7?",
|
||||
"city": "ankara",
|
||||
"date": "2026-03-07",
|
||||
"slug": "m1",
|
||||
"url": "https://polymarket.com/event/m1",
|
||||
"volume": 1000.0,
|
||||
"liquidity": 500.0,
|
||||
"outcomes": [
|
||||
{"name": "6-7°C", "token_id": "t1", "last_price": 0.32},
|
||||
{"name": "8-9°C", "token_id": "t2", "last_price": 0.40},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
books = {
|
||||
"t1": {
|
||||
"bids": [{"price": "0.30", "size": "50"}],
|
||||
"asks": [{"price": "0.36", "size": "55"}],
|
||||
"last_trade_price": "0.34",
|
||||
"timestamp": "2026-03-06T10:00:00Z",
|
||||
},
|
||||
# one-sided book + thin liquidity to trigger anomaly
|
||||
"t2": {
|
||||
"bids": [],
|
||||
"asks": [{"price": "0.52", "size": "10"}],
|
||||
"last_trade_price": "0.51",
|
||||
"timestamp": "2026-03-06T10:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
def fake_get_city_markets(**kwargs):
|
||||
return markets
|
||||
|
||||
def fake_fetch_order_books(*args, **kwargs):
|
||||
return books
|
||||
|
||||
monkeypatch.setattr(pm, "get_city_markets", fake_get_city_markets)
|
||||
monkeypatch.setattr(pm, "fetch_order_books", fake_fetch_order_books)
|
||||
|
||||
# Seed previous snapshot for token t1, so we can detect a price jump alert
|
||||
pm._prev_snapshots["t1"] = {
|
||||
"ts": 1.0,
|
||||
"best_bid": 0.20,
|
||||
"best_ask": 0.24,
|
||||
"spread": 0.04,
|
||||
"last_trade_price": 0.22,
|
||||
}
|
||||
|
||||
snap = pm.build_city_market_snapshot(city="ankara", target_date="2026-03-07")
|
||||
|
||||
assert snap["city"] == "ankara"
|
||||
assert snap["target_date"] == "2026-03-07"
|
||||
assert snap["summary"]["market_count"] == 1
|
||||
assert snap["summary"]["outcome_count"] == 2
|
||||
|
||||
first_market = snap["markets"][0]
|
||||
row_t1 = next(x for x in first_market["outcomes"] if x["token_id"] == "t1")
|
||||
row_t2 = next(x for x in first_market["outcomes"] if x["token_id"] == "t2")
|
||||
|
||||
# Buy uses ask, sell uses bid
|
||||
assert row_t1["buy_price"] == 0.36
|
||||
assert row_t1["sell_price"] == 0.30
|
||||
assert round(row_t1["spread"], 2) == 0.06
|
||||
|
||||
# one-sided orderbook has no sell price
|
||||
assert row_t2["buy_price"] == 0.52
|
||||
assert row_t2["sell_price"] is None
|
||||
assert "one_sided_orderbook" in row_t2["anomaly_flags"]
|
||||
-121
@@ -588,127 +588,6 @@ def _normalize_city_or_404(name: str) -> str:
|
||||
return city
|
||||
|
||||
|
||||
def _resolve_target_date(city: str, target_date: Optional[str]) -> str:
|
||||
"""
|
||||
Resolve requested market date. If absent, default to current local date of city.
|
||||
"""
|
||||
if target_date:
|
||||
try:
|
||||
datetime.strptime(target_date, "%Y-%m-%d")
|
||||
except Exception:
|
||||
raise HTTPException(400, detail="target_date must be YYYY-MM-DD")
|
||||
return target_date
|
||||
|
||||
tz_seconds = CITIES.get(city, {}).get("tz", 0)
|
||||
return (datetime.now(timezone.utc) + timedelta(seconds=tz_seconds)).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/polymarket/{name}")
|
||||
async def city_polymarket_snapshot(
|
||||
name: str,
|
||||
target_date: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
"""
|
||||
Return Polymarket city/date market snapshot with buy/sell prices and spreads.
|
||||
"""
|
||||
city = _normalize_city_or_404(name)
|
||||
resolved_date = _resolve_target_date(city, target_date)
|
||||
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
|
||||
proxy = (
|
||||
(_config.get("polymarket", {}) or {}).get("proxy")
|
||||
or (_config.get("app", {}) or {}).get("proxy")
|
||||
)
|
||||
snapshot = build_city_market_snapshot(
|
||||
city=city,
|
||||
target_date=resolved_date,
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.get("/api/polymarket/{name}/alerts")
|
||||
async def city_polymarket_alerts(
|
||||
name: str,
|
||||
target_date: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
"""
|
||||
Return orderbook anomalies plus strategy-focused trading alerts.
|
||||
"""
|
||||
city = _normalize_city_or_404(name)
|
||||
resolved_date = _resolve_target_date(city, target_date)
|
||||
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
|
||||
proxy = (
|
||||
(_config.get("polymarket", {}) or {}).get("proxy")
|
||||
or (_config.get("app", {}) or {}).get("proxy")
|
||||
)
|
||||
snapshot = build_city_market_snapshot(
|
||||
city=city,
|
||||
target_date=resolved_date,
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||
trade_alerts = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=snapshot,
|
||||
map_url=map_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"city": snapshot.get("city"),
|
||||
"target_date": snapshot.get("target_date"),
|
||||
"updated_at": snapshot.get("updated_at"),
|
||||
"summary": snapshot.get("summary"),
|
||||
"alerts": snapshot.get("alerts", []),
|
||||
"trade_alerts": trade_alerts,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/polymarket/{name}/trade-alerts")
|
||||
async def city_trade_alerts(
|
||||
name: str,
|
||||
target_date: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
"""
|
||||
Return trading alerts and Telegram-ready notification payload.
|
||||
"""
|
||||
city = _normalize_city_or_404(name)
|
||||
resolved_date = _resolve_target_date(city, target_date)
|
||||
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
|
||||
proxy = (
|
||||
(_config.get("polymarket", {}) or {}).get("proxy")
|
||||
or (_config.get("app", {}) or {}).get("proxy")
|
||||
)
|
||||
snapshot = build_city_market_snapshot(
|
||||
city=city,
|
||||
target_date=resolved_date,
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||
return build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=snapshot,
|
||||
map_url=map_url,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/history/{name}")
|
||||
async def city_history(name: str):
|
||||
"""Return historical accuracy data (DEB, mu, actuals) for a city."""
|
||||
|
||||
@@ -255,85 +255,6 @@ async function fetchCityDetail(cityName, force = false) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function fetchCityMarket(cityName, targetDate, force = false) {
|
||||
const urlName = cityName.replace(/\s/g, "-");
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: String(force),
|
||||
});
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function hasMarketSnapshot(data, targetDate) {
|
||||
return Boolean(
|
||||
data?.polymarket &&
|
||||
data.polymarket.target_date === targetDate &&
|
||||
!data.polymarket.loading &&
|
||||
!data.polymarket.fetch_error &&
|
||||
Array.isArray(data.polymarket.markets),
|
||||
);
|
||||
}
|
||||
|
||||
async function hydrateCityMarketData(data, force = false) {
|
||||
if (!data?.name) return null;
|
||||
|
||||
const targetDate = data.local_date || null;
|
||||
if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
|
||||
return data.polymarket;
|
||||
}
|
||||
if (!force && hasMarketSnapshot(data, targetDate)) {
|
||||
return data.polymarket;
|
||||
}
|
||||
|
||||
const existingMarkets = Array.isArray(data.polymarket?.markets)
|
||||
? data.polymarket.markets
|
||||
: [];
|
||||
|
||||
data.polymarket = {
|
||||
...(data.polymarket || {}),
|
||||
target_date: targetDate,
|
||||
loading: true,
|
||||
fetch_error: null,
|
||||
markets: existingMarkets,
|
||||
};
|
||||
|
||||
if (selectedCity === data.name) {
|
||||
renderMarketPrices(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await fetchCityMarket(data.name, targetDate, force);
|
||||
data.polymarket = {
|
||||
...snapshot,
|
||||
loading: false,
|
||||
fetch_error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to load market for ${data.name}:`, e);
|
||||
data.polymarket = {
|
||||
...(data.polymarket || {}),
|
||||
target_date: targetDate,
|
||||
loading: false,
|
||||
fetch_error: e.message || "Unknown error",
|
||||
markets: existingMarkets,
|
||||
};
|
||||
}
|
||||
|
||||
cityDataCache[data.name] = data;
|
||||
saveCache();
|
||||
|
||||
if (selectedCity === data.name) {
|
||||
renderMarketPrices(data);
|
||||
}
|
||||
|
||||
return data.polymarket;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Nearby Map Stations Rendering
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
|
||||
const cachedData = cityDataCache[cityName];
|
||||
renderPanel(cachedData);
|
||||
renderNearbyStations(cachedData);
|
||||
hydrateCityMarketData(cachedData, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
|
||||
cityDataCache[cityName] = data;
|
||||
saveCache();
|
||||
renderPanel(data);
|
||||
hydrateCityMarketData(data, force);
|
||||
|
||||
// Render nearby stations and zoom camera (cinematic or bounds)
|
||||
renderNearbyStations(data);
|
||||
@@ -494,8 +413,6 @@ function renderPanel(data) {
|
||||
renderChart(data);
|
||||
// Probabilities
|
||||
renderProbabilities(data);
|
||||
// Market prices
|
||||
renderMarketPrices(data);
|
||||
// Multi-model & Forecast synchronization
|
||||
if (!selectedForecastDate) {
|
||||
selectedForecastDate = data.local_date;
|
||||
@@ -1008,154 +925,6 @@ function formatCents(price) {
|
||||
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
|
||||
}
|
||||
|
||||
function formatCompactUsd(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
if (n >= 1000) {
|
||||
const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
|
||||
return `$${compact}k`;
|
||||
}
|
||||
return `$${Math.round(n)}`;
|
||||
}
|
||||
|
||||
function formatMarketThreshold(market, fallbackUnit) {
|
||||
const threshold = Number(market?.threshold);
|
||||
if (!Number.isFinite(threshold)) {
|
||||
return market?.question || "Market";
|
||||
}
|
||||
|
||||
const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
|
||||
const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
|
||||
const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
|
||||
const unit = unitRaw === "F" ? "°F" : "°C";
|
||||
|
||||
if (market?.contract_type === "exceed") {
|
||||
return `${value}${unit}+`;
|
||||
}
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
function findOutcome(market, outcomeName) {
|
||||
const target = String(outcomeName || "").toLowerCase();
|
||||
return (market?.outcomes || []).find(
|
||||
(outcome) => String(outcome?.name || "").toLowerCase() === target,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarketPrices(data) {
|
||||
const summary = document.getElementById("marketSummary");
|
||||
const container = document.getElementById("marketBook");
|
||||
const snapshot = data.polymarket || {};
|
||||
const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
|
||||
const targetDate = snapshot.target_date || data.local_date || "--";
|
||||
|
||||
if (snapshot.loading && markets.length === 0) {
|
||||
summary.innerHTML =
|
||||
'<span class="market-muted">Loading current Polymarket markets...</span>';
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.fetch_error && markets.length === 0) {
|
||||
summary.innerHTML = `<span class="market-error">Market load failed: ${escapeHtml(snapshot.fetch_error)}</span>`;
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (markets.length === 0) {
|
||||
summary.innerHTML =
|
||||
'<span class="market-muted">No Polymarket markets for this date</span>';
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
markets.sort((a, b) => {
|
||||
const left = Number(a?.threshold);
|
||||
const right = Number(b?.threshold);
|
||||
const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
|
||||
const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
|
||||
return safeLeft - safeRight;
|
||||
});
|
||||
|
||||
const primaryUrl = markets.find((market) => market?.url)?.url;
|
||||
const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
|
||||
const updatedAt = Number.isFinite(updatedAtTs)
|
||||
? new Date(updatedAtTs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: null;
|
||||
|
||||
summary.innerHTML = `
|
||||
<div class="market-summary-main">
|
||||
<span>${escapeHtml(targetDate)} - ${markets.length} markets</span>
|
||||
<span>Buy = best ask</span>
|
||||
<span>Sell = best bid</span>
|
||||
${updatedAt ? `<span>Updated ${escapeHtml(updatedAt)}</span>` : ""}
|
||||
</div>
|
||||
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">Open Polymarket</a>` : ""}
|
||||
`;
|
||||
|
||||
container.innerHTML = markets
|
||||
.map((market) => {
|
||||
const yes = findOutcome(market, "yes") || {};
|
||||
const no = findOutcome(market, "no") || {};
|
||||
const label = formatMarketThreshold(
|
||||
market,
|
||||
String(data.temp_symbol || "").includes("F") ? "F" : "C",
|
||||
);
|
||||
const spread = Number(yes.spread);
|
||||
const meta = [
|
||||
`Volume ${formatCompactUsd(market.volume)}`,
|
||||
`Liquidity ${formatCompactUsd(market.liquidity)}`,
|
||||
Number.isFinite(spread) ? `Yes spread ${formatCents(spread)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="market-row">
|
||||
<div class="market-contract">
|
||||
<div class="market-threshold">${escapeHtml(label)}</div>
|
||||
<div class="market-contract-meta">${meta.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
|
||||
<div class="market-question">${escapeHtml(market.question || "")}</div>
|
||||
</div>
|
||||
<div class="market-side yes">
|
||||
<div class="market-side-header">
|
||||
<span class="market-side-label yes">YES</span>
|
||||
<span class="market-last">Last ${formatCents(yes.last_trade_price)}</span>
|
||||
</div>
|
||||
<div class="market-side-prices">
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Buy</div>
|
||||
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
|
||||
</div>
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Sell</div>
|
||||
<div class="market-price-value">${formatCents(yes.sell_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="market-side no">
|
||||
<div class="market-side-header">
|
||||
<span class="market-side-label no">NO</span>
|
||||
<span class="market-last">Last ${formatCents(no.last_trade_price)}</span>
|
||||
</div>
|
||||
<div class="market-side-prices">
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Buy</div>
|
||||
<div class="market-price-value">${formatCents(no.buy_price)}</div>
|
||||
</div>
|
||||
<div class="market-price-chip">
|
||||
<div class="market-price-label">Sell</div>
|
||||
<div class="market-price-value">${formatCents(no.sell_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderModels(data) {
|
||||
const container = document.getElementById("modelBars");
|
||||
const targetDate = selectedForecastDate || data.local_date;
|
||||
|
||||
@@ -108,14 +108,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="market-section">
|
||||
<h3>Polymarket Prices</h3>
|
||||
<div id="marketSummary" class="market-summary"></div>
|
||||
<div id="marketBook" class="market-book">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Multi-Model Comparison ── -->
|
||||
<section class="models-section">
|
||||
<h3>🔬 多模型预报</h3>
|
||||
|
||||
@@ -668,155 +668,6 @@ body {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* ── Market Section ── */
|
||||
.market-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-summary-main {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.market-summary a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.market-summary a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.market-book {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.market-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.market-contract {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-threshold {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.market-contract-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-question {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.market-side {
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.market-side.yes {
|
||||
border-color: rgba(34, 197, 94, 0.18);
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.market-side.no {
|
||||
border-color: rgba(248, 113, 113, 0.18);
|
||||
background: rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
|
||||
.market-side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.market-side-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.market-side-label.yes {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.market-side-label.no {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.market-last {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-side-prices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.market-price-chip {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.market-price-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.market-price-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.market-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ── Model Bars ── */
|
||||
.model-bars {
|
||||
display: flex;
|
||||
@@ -1237,9 +1088,6 @@ body {
|
||||
:root {
|
||||
--panel-width: 100%;
|
||||
}
|
||||
.market-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
Reference in New Issue
Block a user