feat: phase 14 — BTC technicals, central bank rates, 3 datasets, dashboard UX (+5 = 84 tools)
New tools: - intel_btc_technicals: SMA-50/200, Mayer Multiple, golden/death cross, ATH distance - intel_central_bank_rates: 15 banks (Fed/ECB/BoE via FRED, 12 curated fallback) - intel_trade_routes: 19 maritime chokepoints with oil flow and vessel transit data - intel_cloud_regions: 28 AWS/Azure/GCP regions with coordinates - intel_financial_centers: GFCI top 20 with rankings and specializations Dashboard additions: - BTC Technicals drawer section (price, SMA, Mayer, cross signal) - Central Bank Rates drawer section (15 banks, sorted by rate) - Trade route markers on infrastructure map layer (chokepoint/canal/route icons) - Regional presets (Globe/Americas/Europe/MENA/Asia-Pac/Africa) with localStorage persistence - Time window filter (1h/6h/24h/7d/All) filtering earthquakes and news by timestamp - Static datasets served via /api/static for instant boot Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39e687b054
commit
4dda867968
@@ -0,0 +1,142 @@
|
||||
"""Strategic maritime trade routes and chokepoints.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: UNCTAD Review of Maritime Transport, US EIA World Transit Chokepoints,
|
||||
MarineTraffic, World Shipping Council.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TRADE_ROUTES — 19 critical maritime chokepoints and shipping lanes
|
||||
# Fields: name, lat, lon, type, daily_vessel_transits, oil_flow_mbd,
|
||||
# trade_value_pct, countries, notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TRADE_ROUTES: list[dict] = [
|
||||
{"name": "Strait of Hormuz", "lat": 26.57, "lon": 56.25, "type": "chokepoint",
|
||||
"daily_vessel_transits": 80, "oil_flow_mbd": 20.5, "trade_value_pct": 21,
|
||||
"countries": ["IRN", "OMN", "ARE"], "notes": "World's most critical oil chokepoint, ~20% of global oil"},
|
||||
{"name": "Strait of Malacca", "lat": 2.50, "lon": 101.50, "type": "chokepoint",
|
||||
"daily_vessel_transits": 200, "oil_flow_mbd": 16.0, "trade_value_pct": 25,
|
||||
"countries": ["MYS", "IDN", "SGP"], "notes": "Shortest route between Pacific and Indian Ocean, ~25% global trade"},
|
||||
{"name": "Suez Canal", "lat": 30.58, "lon": 32.27, "type": "canal",
|
||||
"daily_vessel_transits": 50, "oil_flow_mbd": 5.5, "trade_value_pct": 12,
|
||||
"countries": ["EGY"], "notes": "Connects Mediterranean and Red Sea, 12% global trade"},
|
||||
{"name": "Bab el-Mandeb", "lat": 12.58, "lon": 43.33, "type": "chokepoint",
|
||||
"daily_vessel_transits": 65, "oil_flow_mbd": 6.2, "trade_value_pct": 10,
|
||||
"countries": ["YEM", "DJI", "ERI"], "notes": "Gate to Suez Canal from Indian Ocean, Houthi threat zone"},
|
||||
{"name": "Panama Canal", "lat": 9.08, "lon": -79.68, "type": "canal",
|
||||
"daily_vessel_transits": 35, "oil_flow_mbd": 0.9, "trade_value_pct": 5,
|
||||
"countries": ["PAN"], "notes": "Connects Atlantic and Pacific, drought-restricted since 2023"},
|
||||
{"name": "Turkish Straits (Bosphorus)", "lat": 41.12, "lon": 29.05, "type": "chokepoint",
|
||||
"daily_vessel_transits": 120, "oil_flow_mbd": 3.0, "trade_value_pct": 3,
|
||||
"countries": ["TUR"], "notes": "Connects Black Sea to Mediterranean, Russian oil exports"},
|
||||
{"name": "Danish Straits", "lat": 55.60, "lon": 12.60, "type": "chokepoint",
|
||||
"daily_vessel_transits": 90, "oil_flow_mbd": 3.2, "trade_value_pct": 2,
|
||||
"countries": ["DNK", "SWE"], "notes": "Baltic Sea access, Russian energy exports, Nord Stream corridor"},
|
||||
{"name": "Strait of Gibraltar", "lat": 35.97, "lon": -5.60, "type": "chokepoint",
|
||||
"daily_vessel_transits": 250, "oil_flow_mbd": 3.0, "trade_value_pct": 4,
|
||||
"countries": ["ESP", "MAR", "GBR"], "notes": "Atlantic-Mediterranean gateway"},
|
||||
{"name": "Cape of Good Hope", "lat": -34.36, "lon": 18.47, "type": "route",
|
||||
"daily_vessel_transits": 40, "oil_flow_mbd": 6.0, "trade_value_pct": 8,
|
||||
"countries": ["ZAF"], "notes": "Suez Canal alternative, Houthi rerouting hub since 2024"},
|
||||
{"name": "Lombok Strait", "lat": -8.40, "lon": 115.70, "type": "chokepoint",
|
||||
"daily_vessel_transits": 30, "oil_flow_mbd": 1.5, "trade_value_pct": 2,
|
||||
"countries": ["IDN"], "notes": "Malacca bypass for deep-draft VLCCs"},
|
||||
{"name": "Taiwan Strait", "lat": 24.00, "lon": 118.50, "type": "chokepoint",
|
||||
"daily_vessel_transits": 150, "oil_flow_mbd": 5.0, "trade_value_pct": 20,
|
||||
"countries": ["TWN", "CHN"], "notes": "Semiconductor supply chain route, geopolitical flashpoint"},
|
||||
{"name": "Mozambique Channel", "lat": -17.00, "lon": 42.00, "type": "route",
|
||||
"daily_vessel_transits": 25, "oil_flow_mbd": 2.0, "trade_value_pct": 2,
|
||||
"countries": ["MOZ", "MDG"], "notes": "East African LNG export corridor"},
|
||||
{"name": "Strait of Sunda", "lat": -6.10, "lon": 105.80, "type": "chokepoint",
|
||||
"daily_vessel_transits": 15, "oil_flow_mbd": 0.5, "trade_value_pct": 1,
|
||||
"countries": ["IDN"], "notes": "Java-Sumatra strait, Malacca alternative"},
|
||||
{"name": "Northern Sea Route", "lat": 73.00, "lon": 100.00, "type": "route",
|
||||
"daily_vessel_transits": 5, "oil_flow_mbd": 0.3, "trade_value_pct": 0.1,
|
||||
"countries": ["RUS"], "notes": "Arctic route, seasonal, Russian-controlled, growing LNG traffic"},
|
||||
{"name": "English Channel", "lat": 50.50, "lon": 1.00, "type": "route",
|
||||
"daily_vessel_transits": 500, "oil_flow_mbd": 2.0, "trade_value_pct": 5,
|
||||
"countries": ["GBR", "FRA"], "notes": "Busiest shipping lane in the world by vessel count"},
|
||||
{"name": "Korea Strait", "lat": 34.00, "lon": 129.50, "type": "chokepoint",
|
||||
"daily_vessel_transits": 100, "oil_flow_mbd": 3.5, "trade_value_pct": 4,
|
||||
"countries": ["KOR", "JPN"], "notes": "Sea of Japan access, Japan-Korea trade corridor"},
|
||||
{"name": "Strait of Luzon", "lat": 20.00, "lon": 121.00, "type": "chokepoint",
|
||||
"daily_vessel_transits": 50, "oil_flow_mbd": 3.0, "trade_value_pct": 5,
|
||||
"countries": ["PHL", "TWN"], "notes": "South China Sea-Pacific gateway, PLAN patrol zone"},
|
||||
{"name": "Dover Strait", "lat": 51.05, "lon": 1.40, "type": "chokepoint",
|
||||
"daily_vessel_transits": 400, "oil_flow_mbd": 1.5, "trade_value_pct": 3,
|
||||
"countries": ["GBR", "FRA"], "notes": "Narrowest point of English Channel, TSS enforced"},
|
||||
{"name": "Dardanelles", "lat": 40.20, "lon": 26.40, "type": "chokepoint",
|
||||
"daily_vessel_transits": 120, "oil_flow_mbd": 3.0, "trade_value_pct": 3,
|
||||
"countries": ["TUR"], "notes": "Aegean entrance to Turkish Straits, grain corridor"},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLOUD_REGIONS — Major cloud provider regions (AWS, Azure, GCP)
|
||||
# Fields: name, provider, region_code, lat, lon, launched, zone_count, notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLOUD_REGIONS: list[dict] = [
|
||||
# AWS Major Regions
|
||||
{"name": "US East (Virginia)", "provider": "AWS", "region_code": "us-east-1", "lat": 39.05, "lon": -77.49, "launched": 2006, "zone_count": 6, "notes": "AWS's largest region, default for most services"},
|
||||
{"name": "US West (Oregon)", "provider": "AWS", "region_code": "us-west-2", "lat": 45.95, "lon": -119.57, "launched": 2011, "zone_count": 4, "notes": "Second-largest AWS region"},
|
||||
{"name": "Europe (Ireland)", "provider": "AWS", "region_code": "eu-west-1", "lat": 53.34, "lon": -6.26, "launched": 2007, "zone_count": 3, "notes": "Primary EU region"},
|
||||
{"name": "Europe (Frankfurt)", "provider": "AWS", "region_code": "eu-central-1", "lat": 50.11, "lon": 8.68, "launched": 2014, "zone_count": 3, "notes": "German data sovereignty"},
|
||||
{"name": "Asia Pacific (Tokyo)", "provider": "AWS", "region_code": "ap-northeast-1", "lat": 35.68, "lon": 139.77, "launched": 2011, "zone_count": 4, "notes": "AWS's largest APAC region"},
|
||||
{"name": "Asia Pacific (Singapore)", "provider": "AWS", "region_code": "ap-southeast-1", "lat": 1.35, "lon": 103.82, "launched": 2010, "zone_count": 3, "notes": "Southeast Asia hub"},
|
||||
{"name": "Asia Pacific (Sydney)", "provider": "AWS", "region_code": "ap-southeast-2", "lat": -33.87, "lon": 151.21, "launched": 2012, "zone_count": 3, "notes": "Oceania region"},
|
||||
{"name": "Middle East (Bahrain)", "provider": "AWS", "region_code": "me-south-1", "lat": 26.07, "lon": 50.55, "launched": 2019, "zone_count": 3, "notes": "First AWS MENA region"},
|
||||
{"name": "Africa (Cape Town)", "provider": "AWS", "region_code": "af-south-1", "lat": -33.93, "lon": 18.42, "launched": 2020, "zone_count": 3, "notes": "First AWS Africa region"},
|
||||
{"name": "South America (São Paulo)", "provider": "AWS", "region_code": "sa-east-1", "lat": -23.55, "lon": -46.63, "launched": 2011, "zone_count": 3, "notes": "Only AWS LatAm region"},
|
||||
# Azure Major Regions
|
||||
{"name": "East US", "provider": "Azure", "region_code": "eastus", "lat": 37.37, "lon": -79.15, "launched": 2010, "zone_count": 3, "notes": "Azure's largest region (Virginia)"},
|
||||
{"name": "West Europe", "provider": "Azure", "region_code": "westeurope", "lat": 52.37, "lon": 4.90, "launched": 2010, "zone_count": 3, "notes": "Netherlands, primary EU"},
|
||||
{"name": "North Europe", "provider": "Azure", "region_code": "northeurope", "lat": 53.35, "lon": -6.26, "launched": 2010, "zone_count": 3, "notes": "Ireland"},
|
||||
{"name": "Southeast Asia", "provider": "Azure", "region_code": "southeastasia", "lat": 1.28, "lon": 103.83, "launched": 2010, "zone_count": 3, "notes": "Singapore"},
|
||||
{"name": "Japan East", "provider": "Azure", "region_code": "japaneast", "lat": 35.68, "lon": 139.77, "launched": 2014, "zone_count": 3, "notes": "Tokyo, primary Japan"},
|
||||
{"name": "UAE North", "provider": "Azure", "region_code": "uaenorth", "lat": 25.27, "lon": 55.30, "launched": 2019, "zone_count": 3, "notes": "Dubai"},
|
||||
{"name": "South Africa North", "provider": "Azure", "region_code": "southafricanorth", "lat": -25.73, "lon": 28.22, "launched": 2019, "zone_count": 3, "notes": "Johannesburg"},
|
||||
{"name": "Brazil South", "provider": "Azure", "region_code": "brazilsouth", "lat": -23.55, "lon": -46.63, "launched": 2014, "zone_count": 3, "notes": "São Paulo"},
|
||||
# GCP Major Regions
|
||||
{"name": "US Central (Iowa)", "provider": "GCP", "region_code": "us-central1", "lat": 41.26, "lon": -95.86, "launched": 2012, "zone_count": 4, "notes": "GCP's largest region, Council Bluffs"},
|
||||
{"name": "US East (South Carolina)", "provider": "GCP", "region_code": "us-east1", "lat": 33.84, "lon": -81.16, "launched": 2015, "zone_count": 3, "notes": "Moncks Corner"},
|
||||
{"name": "Europe West (Belgium)", "provider": "GCP", "region_code": "europe-west1", "lat": 50.45, "lon": 3.82, "launched": 2015, "zone_count": 3, "notes": "St. Ghislain"},
|
||||
{"name": "Europe West (London)", "provider": "GCP", "region_code": "europe-west2", "lat": 51.51, "lon": -0.13, "launched": 2017, "zone_count": 3, "notes": "London"},
|
||||
{"name": "Asia East (Taiwan)", "provider": "GCP", "region_code": "asia-east1", "lat": 24.05, "lon": 120.69, "launched": 2014, "zone_count": 3, "notes": "Changhua County"},
|
||||
{"name": "Asia Northeast (Tokyo)", "provider": "GCP", "region_code": "asia-northeast1", "lat": 35.68, "lon": 139.77, "launched": 2016, "zone_count": 3, "notes": "Tokyo"},
|
||||
{"name": "Asia Southeast (Singapore)", "provider": "GCP", "region_code": "asia-southeast1", "lat": 1.35, "lon": 103.82, "launched": 2017, "zone_count": 3, "notes": "Jurong West"},
|
||||
{"name": "Australia Southeast (Melbourne)", "provider": "GCP", "region_code": "australia-southeast1", "lat": -37.81, "lon": 144.96, "launched": 2017, "zone_count": 3, "notes": "Melbourne"},
|
||||
{"name": "South America East (São Paulo)", "provider": "GCP", "region_code": "southamerica-east1", "lat": -23.55, "lon": -46.63, "launched": 2017, "zone_count": 3, "notes": "Osasco"},
|
||||
{"name": "Middle East (Tel Aviv)", "provider": "GCP", "region_code": "me-west1", "lat": 32.09, "lon": 34.77, "launched": 2022, "zone_count": 3, "notes": "Israel"},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FINANCIAL_CENTERS — GFCI top 20 + key regional centers
|
||||
# Fields: name, country, iso3, lat, lon, gfci_rank, gfci_rating,
|
||||
# specialization, exchange, notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINANCIAL_CENTERS: list[dict] = [
|
||||
{"name": "New York", "country": "USA", "iso3": "USA", "lat": 40.71, "lon": -74.01, "gfci_rank": 1, "gfci_rating": 760, "specialization": "equities, derivatives, banking", "exchange": "NYSE/NASDAQ", "notes": "World's largest financial center, $25T+ equity market cap"},
|
||||
{"name": "London", "country": "UK", "iso3": "GBR", "lat": 51.51, "lon": -0.13, "gfci_rank": 2, "gfci_rating": 744, "specialization": "forex, insurance, banking", "exchange": "LSE", "notes": "Largest FX trading hub (~38% global volume)"},
|
||||
{"name": "Singapore", "country": "Singapore", "iso3": "SGP", "lat": 1.28, "lon": 103.85, "gfci_rank": 3, "gfci_rating": 742, "specialization": "wealth management, FX, commodities", "exchange": "SGX", "notes": "Asia-Pacific wealth management hub"},
|
||||
{"name": "Hong Kong", "country": "China (SAR)", "iso3": "HKG", "lat": 22.32, "lon": 114.17, "gfci_rank": 4, "gfci_rating": 741, "specialization": "IPOs, banking, yuan offshore", "exchange": "HKEX", "notes": "Gateway to mainland China capital markets"},
|
||||
{"name": "San Francisco", "country": "USA", "iso3": "USA", "lat": 37.77, "lon": -122.42, "gfci_rank": 5, "gfci_rating": 738, "specialization": "venture capital, fintech", "exchange": "—", "notes": "Global VC capital, tech finance hub"},
|
||||
{"name": "Shanghai", "country": "China", "iso3": "CHN", "lat": 31.23, "lon": 121.47, "gfci_rank": 6, "gfci_rating": 735, "specialization": "equities, bonds, commodities", "exchange": "SSE", "notes": "Largest exchange in mainland China"},
|
||||
{"name": "Los Angeles", "country": "USA", "iso3": "USA", "lat": 34.05, "lon": -118.24, "gfci_rank": 7, "gfci_rating": 733, "specialization": "entertainment finance, VC", "exchange": "—", "notes": "Growing fintech ecosystem"},
|
||||
{"name": "Tokyo", "country": "Japan", "iso3": "JPN", "lat": 35.68, "lon": 139.77, "gfci_rank": 8, "gfci_rating": 730, "specialization": "equities, bonds, banking", "exchange": "JPX", "notes": "Third-largest equity market globally"},
|
||||
{"name": "Seoul", "country": "South Korea", "iso3": "KOR", "lat": 37.57, "lon": 126.98, "gfci_rank": 9, "gfci_rating": 728, "specialization": "equities, crypto, semiconductors", "exchange": "KRX", "notes": "World's busiest crypto trading market"},
|
||||
{"name": "Shenzhen", "country": "China", "iso3": "CHN", "lat": 22.54, "lon": 114.06, "gfci_rank": 10, "gfci_rating": 726, "specialization": "tech equities, IPOs", "exchange": "SZSE", "notes": "ChiNext board, tech-heavy market"},
|
||||
{"name": "Chicago", "country": "USA", "iso3": "USA", "lat": 41.88, "lon": -87.63, "gfci_rank": 11, "gfci_rating": 724, "specialization": "derivatives, commodities, futures", "exchange": "CME/CBOT", "notes": "World's largest derivatives exchange"},
|
||||
{"name": "Sydney", "country": "Australia", "iso3": "AUS", "lat": -33.87, "lon": 151.21, "gfci_rank": 12, "gfci_rating": 722, "specialization": "mining, superannuation", "exchange": "ASX", "notes": "Largest exchange in Southern Hemisphere"},
|
||||
{"name": "Zurich", "country": "Switzerland", "iso3": "CHE", "lat": 47.37, "lon": 8.54, "gfci_rank": 13, "gfci_rating": 720, "specialization": "private banking, insurance", "exchange": "SIX", "notes": "Global private banking capital"},
|
||||
{"name": "Frankfurt", "country": "Germany", "iso3": "DEU", "lat": 50.11, "lon": 8.68, "gfci_rank": 14, "gfci_rating": 718, "specialization": "banking, ECB HQ", "exchange": "Xetra/FWB", "notes": "ECB headquarters, eurozone financial hub"},
|
||||
{"name": "Dubai", "country": "UAE", "iso3": "ARE", "lat": 25.20, "lon": 55.27, "gfci_rank": 15, "gfci_rating": 716, "specialization": "Islamic finance, wealth management", "exchange": "DFM/DIFC", "notes": "Bridge between East and West, DIFC free zone"},
|
||||
{"name": "Paris", "country": "France", "iso3": "FRA", "lat": 48.86, "lon": 2.35, "gfci_rank": 16, "gfci_rating": 714, "specialization": "insurance, asset management", "exchange": "Euronext Paris", "notes": "Post-Brexit gains in euro clearing"},
|
||||
{"name": "Mumbai", "country": "India", "iso3": "IND", "lat": 19.08, "lon": 72.88, "gfci_rank": 17, "gfci_rating": 712, "specialization": "equities, derivatives, banking", "exchange": "BSE/NSE", "notes": "NSE is world's largest derivatives exchange by volume"},
|
||||
{"name": "Toronto", "country": "Canada", "iso3": "CAN", "lat": 43.65, "lon": -79.38, "gfci_rank": 18, "gfci_rating": 710, "specialization": "mining, banking, cannabis", "exchange": "TSX", "notes": "World's #1 mining finance exchange"},
|
||||
{"name": "Abu Dhabi", "country": "UAE", "iso3": "ARE", "lat": 24.45, "lon": 54.65, "gfci_rank": 19, "gfci_rating": 708, "specialization": "sovereign wealth, energy finance", "exchange": "ADX/ADGM", "notes": "ADIA (world's 3rd largest SWF), energy focus"},
|
||||
{"name": "Geneva", "country": "Switzerland", "iso3": "CHE", "lat": 46.20, "lon": 6.14, "gfci_rank": 20, "gfci_rating": 706, "specialization": "private banking, commodity trading", "exchange": "—", "notes": "Global commodity trading capital, wealth management"},
|
||||
]
|
||||
@@ -56,6 +56,8 @@ from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet
|
||||
from world_intel_mcp.config.countries import INTEL_HOTSPOTS, STRATEGIC_WATERWAYS
|
||||
from world_intel_mcp.config.geospatial import MILITARY_BASES, STRATEGIC_PORTS, PIPELINES, NUCLEAR_FACILITIES
|
||||
from world_intel_mcp.sources.infrastructure import CABLE_CORRIDORS
|
||||
from world_intel_mcp.config.trade_routes import TRADE_ROUTES, CLOUD_REGIONS, FINANCIAL_CENTERS
|
||||
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -127,6 +129,8 @@ async def _fetch_overview() -> dict:
|
||||
"traffic_flow": traffic.fetch_traffic_flow(fetcher),
|
||||
"traffic_incidents": traffic.fetch_traffic_incidents(fetcher),
|
||||
"webcams": webcams.fetch_webcams(fetcher),
|
||||
"btc_technicals": markets.fetch_btc_technicals(fetcher),
|
||||
"central_bank_rates": fetch_central_bank_rates(fetcher),
|
||||
}
|
||||
|
||||
# Per-coro timeout so no single slow source blocks the entire dashboard.
|
||||
@@ -187,6 +191,9 @@ async def _fetch_overview() -> dict:
|
||||
result["pipelines"] = {"pipelines": PIPELINES, "count": len(PIPELINES)}
|
||||
result["nuclear_facilities"] = {"facilities": NUCLEAR_FACILITIES, "count": len(NUCLEAR_FACILITIES)}
|
||||
result["waterways"] = {"waterways": STRATEGIC_WATERWAYS, "count": len(STRATEGIC_WATERWAYS)}
|
||||
result["trade_routes"] = {"routes": TRADE_ROUTES, "count": len(TRADE_ROUTES)}
|
||||
result["cloud_regions"] = {"regions": CLOUD_REGIONS, "count": len(CLOUD_REGIONS)}
|
||||
result["financial_centers"] = {"centers": FINANCIAL_CENTERS, "count": len(FINANCIAL_CENTERS)}
|
||||
result["cable_corridors"] = {
|
||||
"corridors": [
|
||||
{"name": n, "lat_range": c["lat_range"], "lon_range": c["lon_range"], "cables": c["cables"]}
|
||||
@@ -275,6 +282,9 @@ async def api_static(request):
|
||||
],
|
||||
"count": len(CABLE_CORRIDORS),
|
||||
},
|
||||
"trade_routes": {"routes": TRADE_ROUTES, "count": len(TRADE_ROUTES)},
|
||||
"cloud_regions": {"regions": CLOUD_REGIONS, "count": len(CLOUD_REGIONS)},
|
||||
"financial_centers": {"centers": FINANCIAL_CENTERS, "count": len(FINANCIAL_CENTERS)},
|
||||
}, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
|
||||
@@ -140,6 +140,14 @@ a { color: var(--accent); text-decoration: none; }
|
||||
padding: 5px 0; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.layer-row:hover { opacity: 0.85; }
|
||||
.rgn-btn {
|
||||
font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.5px;
|
||||
padding: 3px 7px; border: 1px solid rgba(255,255,255,0.12); border-radius: 4px;
|
||||
background: rgba(255,255,255,0.04); color: var(--dim); cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.rgn-btn:hover { background: rgba(255,255,255,0.1); color: var(--text); }
|
||||
.rgn-btn.active { background: rgba(0,229,255,0.15); color: var(--accent); border-color: rgba(0,229,255,0.3); }
|
||||
.layer-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.layer-label { font-size: 0.75rem; color: var(--text); flex: 1; }
|
||||
.layer-count {
|
||||
@@ -403,6 +411,21 @@ a { color: var(--accent); text-decoration: none; }
|
||||
cursor: pointer; transition: transform 0.15s, opacity 0.15s;
|
||||
}
|
||||
.mk-waterway:hover { transform: rotate(45deg) scale(1.5); opacity: 1; }
|
||||
.mk-chokepoint {
|
||||
width: 8px; height: 8px; background: #ff5722; border-radius: 50%; opacity: 0.9;
|
||||
border: 1.5px solid rgba(255,87,34,0.6); cursor: pointer; transition: transform 0.15s;
|
||||
}
|
||||
.mk-chokepoint:hover { transform: scale(1.8); }
|
||||
.mk-canal {
|
||||
width: 8px; height: 8px; background: #ffab00; border-radius: 2px; opacity: 0.9;
|
||||
border: 1.5px solid rgba(255,171,0,0.6); cursor: pointer; transition: transform 0.15s;
|
||||
}
|
||||
.mk-canal:hover { transform: scale(1.8); }
|
||||
.mk-sealane {
|
||||
width: 6px; height: 6px; background: #00bfa5; border-radius: 50%; opacity: 0.7;
|
||||
border: 1px solid rgba(0,191,165,0.5); cursor: pointer; transition: transform 0.15s;
|
||||
}
|
||||
.mk-sealane:hover { transform: scale(1.8); }
|
||||
.cable-corridor { cursor: pointer; transition: fill-opacity 0.15s; }
|
||||
.cable-corridor:hover { fill-opacity: 0.1 !important; stroke-opacity: 0.5 !important; }
|
||||
.mk-plane {
|
||||
@@ -654,6 +677,27 @@ a { color: var(--accent); text-decoration: none; }
|
||||
<span class="layer-count" id="cntNews">0</span>
|
||||
<div class="toggle on" style="--lc:var(--accent)"><div class="knob"></div></div>
|
||||
</div>
|
||||
<div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:4px;padding-top:6px">
|
||||
<div style="font-size:0.55rem;letter-spacing:1px;color:rgba(255,255,255,0.35);margin-bottom:4px">REGION</div>
|
||||
<div id="regionPresets" style="display:flex;flex-wrap:wrap;gap:2px">
|
||||
<button class="rgn-btn active" data-region="global">Globe</button>
|
||||
<button class="rgn-btn" data-region="americas">Americas</button>
|
||||
<button class="rgn-btn" data-region="europe">Europe</button>
|
||||
<button class="rgn-btn" data-region="mena">MENA</button>
|
||||
<button class="rgn-btn" data-region="asia">Asia-Pac</button>
|
||||
<button class="rgn-btn" data-region="africa">Africa</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:4px;padding-top:6px">
|
||||
<div style="font-size:0.55rem;letter-spacing:1px;color:rgba(255,255,255,0.35);margin-bottom:4px">TIME WINDOW</div>
|
||||
<div id="timeFilter" style="display:flex;gap:2px">
|
||||
<button class="rgn-btn" data-hours="1">1h</button>
|
||||
<button class="rgn-btn" data-hours="6">6h</button>
|
||||
<button class="rgn-btn active" data-hours="24">24h</button>
|
||||
<button class="rgn-btn" data-hours="168">7d</button>
|
||||
<button class="rgn-btn" data-hours="0">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- DATA DRAWER -->
|
||||
@@ -1271,7 +1315,9 @@ document.addEventListener('keydown', function(e) {
|
||||
function updateMapQuakes(data) {
|
||||
mapLayers.quakes.clearLayers();
|
||||
if (!data || data.error) { $('#cntQuakes').textContent = '0'; return; }
|
||||
var quakes = data.earthquakes || [];
|
||||
var quakes = (data.earthquakes || []).filter(function(q) {
|
||||
return _withinTimeWindow(q.time || q.timestamp);
|
||||
});
|
||||
$('#cntQuakes').textContent = quakes.length;
|
||||
quakes.slice(0, 100).forEach(function(q) {
|
||||
if (q.latitude == null || q.longitude == null) return;
|
||||
@@ -1490,6 +1536,27 @@ function updateMapInfra(data) {
|
||||
mk.bindTooltip('<b>' + esc(w.name) + '</b><br><span style="opacity:0.7">' + esc(w.throughput || '') + '</span>', { className: 'mk-tip', direction: 'top', offset: [0, -6] });
|
||||
mk.addTo(mapLayers.infra);
|
||||
});
|
||||
// Trade routes / maritime chokepoints (anchor markers)
|
||||
var trs = (data.trade_routes && data.trade_routes.routes) || [];
|
||||
trs.forEach(function(tr) {
|
||||
if (tr.lat == null || tr.lon == null) return;
|
||||
total++;
|
||||
var iconCls = tr.type === 'chokepoint' ? 'mk-chokepoint' : tr.type === 'canal' ? 'mk-canal' : 'mk-sealane';
|
||||
var mk = L.marker([tr.lat, tr.lon], {
|
||||
icon: L.divIcon({ className: iconCls, iconSize: [8, 8], iconAnchor: [4, 4] })
|
||||
});
|
||||
mk.bindTooltip('<b>' + esc(tr.name) + '</b><br>' +
|
||||
'<span style="opacity:0.7">' + esc(tr.type) + ' · ' + (tr.oil_flow_mbd || 0) + ' mbd oil · ~' + (tr.daily_vessel_transits || 0) + ' vessels/day</span>',
|
||||
{ className: 'mk-tip', direction: 'top', offset: [0, -5] });
|
||||
mk.on('click', function() {
|
||||
showDetail('trade_route', {
|
||||
name: tr.name, type: tr.type, oil_flow_mbd: tr.oil_flow_mbd,
|
||||
daily_transits: tr.daily_vessel_transits, trade_value_pct: tr.trade_value_pct,
|
||||
countries: (tr.countries || []).join(', '), notes: tr.notes
|
||||
});
|
||||
});
|
||||
mk.addTo(mapLayers.infra);
|
||||
});
|
||||
// Submarine cable corridors (shaded rectangles)
|
||||
var cables = (data.cable_corridors && data.cable_corridors.corridors) || [];
|
||||
var cableStyle = { color: '#4da8ff', weight: 1, opacity: 0.2, fillColor: '#4da8ff', fillOpacity: 0.04, dashArray: '4 3', className: 'cable-corridor', interactive: false };
|
||||
@@ -1677,7 +1744,9 @@ function _geolocateArticle(article) {
|
||||
function updateMapNews(data) {
|
||||
mapLayers.news.clearLayers();
|
||||
if (!data || data.error) { $('#cntNews').textContent = '0'; return; }
|
||||
var articles = data.articles || data.items || [];
|
||||
var articles = (data.articles || data.items || []).filter(function(a) {
|
||||
return _withinTimeWindow(a.published || a.pub_date || a.timestamp);
|
||||
});
|
||||
var placed = 0;
|
||||
articles.forEach(function(a) {
|
||||
var geo = _geolocateArticle(a);
|
||||
@@ -2488,6 +2557,46 @@ function updateDrawer(data) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── BTC TECHNICALS ──
|
||||
if (data.btc_technicals && !data.btc_technicals.error && data.btc_technicals.price) {
|
||||
var bt = data.btc_technicals;
|
||||
h += '<div class="sh">BTC TECHNICALS</div>';
|
||||
var crossCls = bt.cross_signal === 'golden_cross' ? 'good' : bt.cross_signal === 'death_cross' ? 'crit' : 'dim';
|
||||
var crossLabel = bt.cross_signal === 'golden_cross' ? 'GOLDEN CROSS' : bt.cross_signal === 'death_cross' ? 'DEATH CROSS' : 'NEUTRAL';
|
||||
h += '<div class="mini-boxes" style="margin:6px 0">';
|
||||
h += '<div class="mini-box"><div class="v">$' + num(bt.price) + '</div><div class="l">BTC Price</div></div>';
|
||||
h += '<div class="mini-box"><div class="v">' + (bt.mayer_multiple || '—') + '</div><div class="l">Mayer Multiple</div></div>';
|
||||
h += '<div class="mini-box"><div class="v ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></div>';
|
||||
h += '</div>';
|
||||
h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + num(bt.sma_50) + '</span></div>';
|
||||
if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + num(bt.sma_200) + '</span></div>';
|
||||
if (bt.change_7d_pct != null) {
|
||||
var c7 = bt.change_7d_pct >= 0 ? 'good' : 'crit';
|
||||
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">7d</span> <span class="' + c7 + '">' + (bt.change_7d_pct > 0 ? '+' : '') + bt.change_7d_pct + '%</span>';
|
||||
if (bt.change_30d_pct != null) {
|
||||
var c30 = bt.change_30d_pct >= 0 ? 'good' : 'crit';
|
||||
h += ' <span class="bright">30d</span> <span class="' + c30 + '">' + (bt.change_30d_pct > 0 ? '+' : '') + bt.change_30d_pct + '%</span>';
|
||||
}
|
||||
h += '</div>';
|
||||
}
|
||||
if (bt.ath_distance_pct != null) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">From ATH</span> <span class="dim">' + bt.ath_distance_pct + '%</span></div>';
|
||||
}
|
||||
|
||||
// ── CENTRAL BANK RATES ──
|
||||
if (data.central_bank_rates && !data.central_bank_rates.error && (data.central_bank_rates.rates || []).length > 0) {
|
||||
var cbr = data.central_bank_rates;
|
||||
h += '<div class="sh">CENTRAL BANK RATES</div>';
|
||||
h += '<div class="dim" style="font-size:0.6rem;padding:2px 0">' + cbr.count + ' banks' + (cbr.fred_available ? ' (FRED live)' : ' (curated)') + '</div>';
|
||||
cbr.rates.forEach(function(r) {
|
||||
var rateCls = r.rate >= 10 ? 'crit' : r.rate >= 5 ? 'warn' : r.rate >= 2 ? 'bright' : 'good';
|
||||
h += '<div style="font-size:0.65rem;padding:1px 0">';
|
||||
h += '<span class="' + rateCls + '" style="min-width:40px;display:inline-block;text-align:right">' + r.rate.toFixed(2) + '%</span> ';
|
||||
h += '<span class="bright">' + esc(r.label) + '</span>';
|
||||
if (r.country) h += ' <span class="dim">(' + esc(r.country) + ')</span>';
|
||||
h += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── DATA FRESHNESS ──
|
||||
if (data.cache_freshness && Object.keys(data.cache_freshness).length > 0) {
|
||||
var cf = data.cache_freshness;
|
||||
@@ -2550,8 +2659,11 @@ function updateTicker(data) {
|
||||
|
||||
// ════════════ MASTER UPDATE ════════════
|
||||
|
||||
function refreshDashboard(data) { updateAll(data); }
|
||||
|
||||
function updateAll(data) {
|
||||
latestData = data;
|
||||
window._lastSSEData = data;
|
||||
|
||||
// Map layers (each wrapped to prevent cascade failures)
|
||||
var conflictData = (data.acled_events && !data.acled_events.error && data.acled_events.count > 0) ? data.acled_events
|
||||
@@ -2781,6 +2893,72 @@ window.addEventListener('resize', _syncLayersTop);
|
||||
$('#hudStats').innerHTML = '<div class="stat-pill"><span class="l" style="animation:pulse-dot 2s ease-in-out infinite">LOADING LIVE FEEDS</span></div>';
|
||||
$('#drawerBody').innerHTML = '<div style="padding:30px;text-align:center"><div class="load-ring" style="width:28px;height:28px;margin:0 auto 10px"></div><div class="dim" style="font-size:0.6rem;letter-spacing:1.5px">LOADING INTELLIGENCE FEEDS</div></div>';
|
||||
|
||||
// ═══════════ REGIONAL PRESETS ═══════════
|
||||
var REGION_VIEWS = {
|
||||
global: { center: [20, 0], zoom: 3 },
|
||||
americas: { center: [10, -80], zoom: 4 },
|
||||
europe: { center: [50, 15], zoom: 5 },
|
||||
mena: { center: [28, 40], zoom: 5 },
|
||||
asia: { center: [20, 110], zoom: 4 },
|
||||
africa: { center: [0, 25], zoom: 4 },
|
||||
};
|
||||
$$('#regionPresets .rgn-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var region = btn.dataset.region;
|
||||
var view = REGION_VIEWS[region];
|
||||
if (!view) return;
|
||||
$$('#regionPresets .rgn-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
map.flyTo(view.center, view.zoom, { duration: 1.2 });
|
||||
try { localStorage.setItem('phoenix-region', region); } catch(e) {}
|
||||
});
|
||||
});
|
||||
// Restore saved region
|
||||
try {
|
||||
var savedRegion = localStorage.getItem('phoenix-region');
|
||||
if (savedRegion && REGION_VIEWS[savedRegion]) {
|
||||
$$('#regionPresets .rgn-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.region === savedRegion);
|
||||
});
|
||||
var sv = REGION_VIEWS[savedRegion];
|
||||
map.setView(sv.center, sv.zoom);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// ═══════════ TIME WINDOW FILTER ═══════════
|
||||
var _timeFilterHours = 24;
|
||||
$$('#timeFilter .rgn-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
_timeFilterHours = parseInt(btn.dataset.hours) || 0;
|
||||
$$('#timeFilter .rgn-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
try { localStorage.setItem('phoenix-time-filter', String(_timeFilterHours)); } catch(e) {}
|
||||
// Re-render with current data if available
|
||||
if (window._lastSSEData) refreshDashboard(window._lastSSEData);
|
||||
});
|
||||
});
|
||||
// Restore saved time filter
|
||||
try {
|
||||
var savedTime = localStorage.getItem('phoenix-time-filter');
|
||||
if (savedTime !== null) {
|
||||
_timeFilterHours = parseInt(savedTime) || 0;
|
||||
$$('#timeFilter .rgn-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.hours === savedTime);
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Time filter helper: check if an ISO timestamp is within the active window
|
||||
function _withinTimeWindow(isoTimestamp) {
|
||||
if (_timeFilterHours === 0) return true; // "All" = no filter
|
||||
if (!isoTimestamp) return true; // No timestamp = show
|
||||
try {
|
||||
var ts = new Date(isoTimestamp).getTime();
|
||||
var cutoff = Date.now() - (_timeFilterHours * 3600000);
|
||||
return ts >= cutoff;
|
||||
} catch(e) { return true; }
|
||||
}
|
||||
|
||||
// Load static geospatial data immediately (bases, ports, nuclear facilities).
|
||||
fetch('/api/static')
|
||||
.then(function(r) { return r.json(); })
|
||||
|
||||
@@ -21,6 +21,8 @@ Phase 11: Strategic synthesis — strategic posture, world brief, fleet report,
|
||||
Phase 12: Extended geospatial (cables, datacenters, spaceports, minerals, exchanges), country stocks,
|
||||
aircraft batch, Hacker News, GitHub trending, arXiv papers, USA spending,
|
||||
NASA EONET, GDACS disaster alerts (+14 = 82 tools).
|
||||
Phase 13: USNI fleet tracker, RSS expansion, report removal.
|
||||
Phase 14: BTC technicals, central bank rates, trade routes, cloud regions, financial centers (+5 = 87 tools).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -36,7 +38,7 @@ from mcp.types import Tool, TextContent
|
||||
from .cache import Cache
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .fetcher import Fetcher
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental, usni_fleet
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental, usni_fleet, central_banks
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("WORLD_INTEL_LOG_LEVEL", "INFO"),
|
||||
@@ -851,6 +853,54 @@ TOOLS: list[Tool] = [
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- BTC Technicals (1 tool) ---
|
||||
Tool(
|
||||
name="intel_btc_technicals",
|
||||
description="Bitcoin technical indicators: SMA-50, SMA-200, Mayer Multiple, golden/death cross, distance from ATH, 7d/30d changes.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
# --- Central Banks (1 tool) ---
|
||||
Tool(
|
||||
name="intel_central_bank_rates",
|
||||
description="Policy rates for 15 major central banks: Fed, ECB, BoE, BoJ, PBoC, RBI, RBA, BoC, SNB, BCB, BoK, CBRT, SARB, Banxico, BI. Live FRED data when API key set, curated fallback otherwise.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
# --- Trade Routes (1 tool) ---
|
||||
Tool(
|
||||
name="intel_trade_routes",
|
||||
description="19 critical maritime chokepoints and trade routes with oil flow (mbd), daily vessel transits, trade value share. Optional: route_type (chokepoint/canal/route), country (ISO-3).",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"route_type": {"type": "string", "description": "Filter: chokepoint, canal, route"},
|
||||
"country": {"type": "string", "description": "Filter by ISO-3 country code"},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Cloud Regions (1 tool) ---
|
||||
Tool(
|
||||
name="intel_cloud_regions",
|
||||
description="28 major cloud provider regions (AWS, Azure, GCP) with coordinates, zone counts, and launch dates. Optional: provider, country.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {"type": "string", "description": "Filter: AWS, Azure, GCP"},
|
||||
"country": {"type": "string", "description": "Filter by region name substring"},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Financial Centers (1 tool) ---
|
||||
Tool(
|
||||
name="intel_financial_centers",
|
||||
description="GFCI top 20 global financial centers with rankings, ratings, specializations, and exchange info. Optional: country (ISO-3), min_rank.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {"type": "string", "description": "Filter by ISO-3 country code"},
|
||||
"min_rank": {"type": "integer", "description": "Only include centers ranked this or better"},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- System (1 tool) ---
|
||||
Tool(
|
||||
name="intel_status",
|
||||
@@ -1205,6 +1255,35 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
case "intel_usni_fleet":
|
||||
return await usni_fleet.fetch_usni_fleet(fetcher)
|
||||
|
||||
# BTC Technicals
|
||||
case "intel_btc_technicals":
|
||||
return await markets.fetch_btc_technicals(fetcher)
|
||||
|
||||
# Central Bank Rates
|
||||
case "intel_central_bank_rates":
|
||||
return await central_banks.fetch_central_bank_rates(fetcher)
|
||||
|
||||
# Trade Routes
|
||||
case "intel_trade_routes":
|
||||
return await geospatial.fetch_trade_routes(
|
||||
route_type=arguments.get("route_type"),
|
||||
country=arguments.get("country"),
|
||||
)
|
||||
|
||||
# Cloud Regions
|
||||
case "intel_cloud_regions":
|
||||
return await geospatial.fetch_cloud_regions(
|
||||
provider=arguments.get("provider"),
|
||||
country=arguments.get("country"),
|
||||
)
|
||||
|
||||
# Financial Centers
|
||||
case "intel_financial_centers":
|
||||
return await geospatial.fetch_financial_centers(
|
||||
country=arguments.get("country"),
|
||||
min_rank=arguments.get("min_rank"),
|
||||
)
|
||||
|
||||
# Environmental
|
||||
case "intel_environmental_events":
|
||||
return await environmental.fetch_environmental_events(
|
||||
@@ -1274,7 +1353,7 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
"social": ["reddit-public"],
|
||||
"nuclear": ["usgs-nuclear-monitor"],
|
||||
"service_status": ["aws", "azure", "gcp", "cloudflare", "github"],
|
||||
"geospatial": ["static-datasets (bases, ports, pipelines, nuclear, cables, datacenters, spaceports, minerals, exchanges)"],
|
||||
"geospatial": ["static-datasets (bases, ports, pipelines, nuclear, cables, datacenters, spaceports, minerals, exchanges, trade-routes, cloud-regions, financial-centers)"],
|
||||
"nlp": ["regex-ner", "keyword-classifier", "jaccard-clustering", "keyword-spike-detector"],
|
||||
"synthesis": ["strategic-posture", "world-brief", "fleet-report", "population-exposure"],
|
||||
"tech": ["hackernews", "github", "arxiv"],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Central bank policy rates from FRED and curated data.
|
||||
|
||||
Fetches the Federal Funds Rate, ECB deposit rate, and other major central
|
||||
bank policy rates. Uses FRED API when available (free key), falls back to
|
||||
a curated static dataset for non-FRED banks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.central_banks")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FRED series IDs for central bank rates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRED_RATE_SERIES: dict[str, dict] = {
|
||||
"fed_funds": {
|
||||
"series_id": "DFF",
|
||||
"bank": "Federal Reserve",
|
||||
"country": "USA",
|
||||
"currency": "USD",
|
||||
"label": "Federal Funds Rate",
|
||||
},
|
||||
"ecb_deposit": {
|
||||
"series_id": "ECBDFR",
|
||||
"bank": "European Central Bank",
|
||||
"country": "EUR",
|
||||
"currency": "EUR",
|
||||
"label": "ECB Deposit Facility Rate",
|
||||
},
|
||||
"boe_bank_rate": {
|
||||
"series_id": "IUDSOIA",
|
||||
"bank": "Bank of England",
|
||||
"country": "GBR",
|
||||
"currency": "GBP",
|
||||
"label": "BoE Bank Rate (SONIA)",
|
||||
},
|
||||
}
|
||||
|
||||
_FRED_API_URL = "https://api.stlouisfed.org/fred/series/observations"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated fallback rates (updated periodically)
|
||||
# These are the most recent known policy rates for banks not on FRED.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CURATED_RATES: list[dict] = [
|
||||
{"bank": "Bank of Japan", "country": "JPN", "currency": "JPY",
|
||||
"label": "BoJ Policy Rate", "rate": 0.50, "as_of": "2025-01-24",
|
||||
"notes": "Short-term policy rate target"},
|
||||
{"bank": "People's Bank of China", "country": "CHN", "currency": "CNY",
|
||||
"label": "PBoC LPR 1Y", "rate": 3.10, "as_of": "2025-01-20",
|
||||
"notes": "1-year Loan Prime Rate"},
|
||||
{"bank": "Reserve Bank of India", "country": "IND", "currency": "INR",
|
||||
"label": "RBI Repo Rate", "rate": 6.50, "as_of": "2025-02-07",
|
||||
"notes": "Policy repo rate"},
|
||||
{"bank": "Reserve Bank of Australia", "country": "AUS", "currency": "AUD",
|
||||
"label": "RBA Cash Rate", "rate": 4.35, "as_of": "2024-11-05",
|
||||
"notes": "Cash rate target"},
|
||||
{"bank": "Bank of Canada", "country": "CAN", "currency": "CAD",
|
||||
"label": "BoC Overnight Rate", "rate": 3.25, "as_of": "2024-12-11",
|
||||
"notes": "Target for the overnight rate"},
|
||||
{"bank": "Swiss National Bank", "country": "CHE", "currency": "CHF",
|
||||
"label": "SNB Policy Rate", "rate": 0.50, "as_of": "2024-12-12",
|
||||
"notes": "SNB policy rate"},
|
||||
{"bank": "Central Bank of Brazil", "country": "BRA", "currency": "BRL",
|
||||
"label": "BCB SELIC", "rate": 13.25, "as_of": "2025-01-29",
|
||||
"notes": "SELIC target rate"},
|
||||
{"bank": "Bank of Korea", "country": "KOR", "currency": "KRW",
|
||||
"label": "BoK Base Rate", "rate": 3.00, "as_of": "2025-01-16",
|
||||
"notes": "Base rate"},
|
||||
{"bank": "Central Bank of Turkey", "country": "TUR", "currency": "TRY",
|
||||
"label": "CBRT Policy Rate", "rate": 45.00, "as_of": "2025-01-23",
|
||||
"notes": "1-week repo rate"},
|
||||
{"bank": "South African Reserve Bank", "country": "ZAF", "currency": "ZAR",
|
||||
"label": "SARB Repo Rate", "rate": 7.75, "as_of": "2024-11-21",
|
||||
"notes": "Repurchase rate"},
|
||||
{"bank": "Banco de México", "country": "MEX", "currency": "MXN",
|
||||
"label": "Banxico Target Rate", "rate": 10.00, "as_of": "2025-02-06",
|
||||
"notes": "Overnight interbank rate target"},
|
||||
{"bank": "Bank Indonesia", "country": "IDN", "currency": "IDR",
|
||||
"label": "BI Rate", "rate": 5.75, "as_of": "2025-01-15",
|
||||
"notes": "BI-Rate"},
|
||||
]
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def _fetch_fred_rate(fetcher: Fetcher, series_id: str, meta: dict) -> dict | None:
|
||||
"""Fetch latest observation for a FRED series."""
|
||||
api_key = os.environ.get("FRED_API_KEY")
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_FRED_API_URL,
|
||||
source="fred",
|
||||
cache_key=f"central_banks:fred:{series_id}",
|
||||
cache_ttl=3600,
|
||||
params={
|
||||
"series_id": series_id,
|
||||
"api_key": api_key,
|
||||
"file_type": "json",
|
||||
"sort_order": "desc",
|
||||
"limit": "1",
|
||||
},
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
obs = data["observations"][0]
|
||||
value = obs.get("value", ".")
|
||||
if value == ".":
|
||||
return None
|
||||
return {
|
||||
"bank": meta["bank"],
|
||||
"country": meta["country"],
|
||||
"currency": meta["currency"],
|
||||
"label": meta["label"],
|
||||
"rate": float(value),
|
||||
"as_of": obs.get("date"),
|
||||
"source": "fred",
|
||||
}
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
logger.warning("FRED parse error for %s", series_id)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_central_bank_rates(fetcher: Fetcher) -> dict:
|
||||
"""Fetch policy rates for 15 major central banks.
|
||||
|
||||
Uses FRED API for Fed, ECB, and BoE when FRED_API_KEY is set.
|
||||
Falls back to curated static data for all other banks.
|
||||
|
||||
Returns::
|
||||
|
||||
{"rates": [{bank, country, currency, rate, as_of, source}],
|
||||
"count": int, "source": "multi", "timestamp": "<iso>"}
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Try FRED for the banks we have series for
|
||||
fred_tasks = []
|
||||
for key, meta in _FRED_RATE_SERIES.items():
|
||||
fred_tasks.append(_fetch_fred_rate(fetcher, meta["series_id"], meta))
|
||||
|
||||
fred_results = await asyncio.gather(*fred_tasks)
|
||||
|
||||
rates: list[dict] = []
|
||||
fred_banks: set[str] = set()
|
||||
|
||||
for result in fred_results:
|
||||
if result is not None:
|
||||
rates.append(result)
|
||||
fred_banks.add(result["bank"])
|
||||
|
||||
# Add curated rates (skip any that FRED already provided)
|
||||
for entry in _CURATED_RATES:
|
||||
if entry["bank"] not in fred_banks:
|
||||
rates.append({**entry, "source": "curated"})
|
||||
|
||||
# If FRED was unavailable, add curated versions of FRED banks too
|
||||
fred_curated_fallback = [
|
||||
{"bank": "Federal Reserve", "country": "USA", "currency": "USD",
|
||||
"label": "Federal Funds Rate", "rate": 4.50, "as_of": "2025-01-29",
|
||||
"notes": "Fed funds target rate (upper bound)"},
|
||||
{"bank": "European Central Bank", "country": "EUR", "currency": "EUR",
|
||||
"label": "ECB Deposit Facility Rate", "rate": 2.75, "as_of": "2025-01-30",
|
||||
"notes": "Deposit facility rate"},
|
||||
{"bank": "Bank of England", "country": "GBR", "currency": "GBP",
|
||||
"label": "BoE Bank Rate", "rate": 4.50, "as_of": "2025-02-06",
|
||||
"notes": "Bank rate"},
|
||||
]
|
||||
for entry in fred_curated_fallback:
|
||||
if entry["bank"] not in fred_banks:
|
||||
rates.append({**entry, "source": "curated"})
|
||||
|
||||
# Sort by rate descending (most hawkish first)
|
||||
rates.sort(key=lambda r: r.get("rate", 0), reverse=True)
|
||||
|
||||
return {
|
||||
"rates": rates,
|
||||
"count": len(rates),
|
||||
"fred_available": bool(os.environ.get("FRED_API_KEY")),
|
||||
"source": "multi",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from ..config.datacenters import AI_DATACENTERS, query_datacenters
|
||||
from ..config.spaceports import SPACEPORTS, query_spaceports
|
||||
from ..config.minerals import CRITICAL_MINERALS, query_minerals
|
||||
from ..config.exchanges import STOCK_EXCHANGES, query_exchanges
|
||||
from ..config.trade_routes import TRADE_ROUTES, CLOUD_REGIONS, FINANCIAL_CENTERS
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
@@ -295,3 +296,102 @@ async def fetch_stock_exchanges(
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_trade_routes(
|
||||
route_type: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> dict:
|
||||
"""Query maritime trade routes and chokepoints.
|
||||
|
||||
Args:
|
||||
route_type: Filter by type (chokepoint, canal, route).
|
||||
country: Filter by ISO-3 country code in the countries list.
|
||||
"""
|
||||
routes = list(TRADE_ROUTES)
|
||||
if route_type:
|
||||
routes = [r for r in routes if r["type"] == route_type.lower()]
|
||||
if country:
|
||||
c = country.upper()
|
||||
routes = [r for r in routes if c in r.get("countries", [])]
|
||||
|
||||
total_oil = sum(r.get("oil_flow_mbd", 0) for r in routes)
|
||||
by_type: dict[str, int] = {}
|
||||
for r in routes:
|
||||
by_type[r["type"]] = by_type.get(r["type"], 0) + 1
|
||||
|
||||
return {
|
||||
"routes": routes,
|
||||
"count": len(routes),
|
||||
"total_in_database": len(TRADE_ROUTES),
|
||||
"total_oil_flow_mbd": round(total_oil, 1),
|
||||
"by_type": by_type,
|
||||
"filters": {"route_type": route_type, "country": country},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_cloud_regions(
|
||||
provider: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> dict:
|
||||
"""Query major cloud provider regions (AWS, Azure, GCP).
|
||||
|
||||
Args:
|
||||
provider: Filter by provider (AWS, Azure, GCP).
|
||||
country: Filter by region name substring.
|
||||
"""
|
||||
regions = list(CLOUD_REGIONS)
|
||||
if provider:
|
||||
p = provider.upper()
|
||||
regions = [r for r in regions if r["provider"].upper() == p]
|
||||
if country:
|
||||
c = country.lower()
|
||||
regions = [r for r in regions if c in r["name"].lower()]
|
||||
|
||||
by_provider: dict[str, int] = {}
|
||||
for r in regions:
|
||||
by_provider[r["provider"]] = by_provider.get(r["provider"], 0) + 1
|
||||
|
||||
return {
|
||||
"regions": regions,
|
||||
"count": len(regions),
|
||||
"total_in_database": len(CLOUD_REGIONS),
|
||||
"by_provider": by_provider,
|
||||
"filters": {"provider": provider, "country": country},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_financial_centers(
|
||||
country: str | None = None,
|
||||
min_rank: int | None = None,
|
||||
) -> dict:
|
||||
"""Query global financial centers (GFCI top 20+).
|
||||
|
||||
Args:
|
||||
country: Filter by ISO-3 country code.
|
||||
min_rank: Only include centers ranked this or better (lower = better).
|
||||
"""
|
||||
centers = list(FINANCIAL_CENTERS)
|
||||
if country:
|
||||
c = country.upper()
|
||||
centers = [fc for fc in centers if fc["iso3"] == c]
|
||||
if min_rank is not None:
|
||||
centers = [fc for fc in centers if fc["gfci_rank"] <= min_rank]
|
||||
|
||||
by_country: dict[str, int] = {}
|
||||
for fc in centers:
|
||||
by_country[fc["iso3"]] = by_country.get(fc["iso3"], 0) + 1
|
||||
|
||||
return {
|
||||
"centers": centers,
|
||||
"count": len(centers),
|
||||
"total_in_database": len(FINANCIAL_CENTERS),
|
||||
"by_country": by_country,
|
||||
"filters": {"country": country, "min_rank": min_rank},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
@@ -446,6 +446,72 @@ async def fetch_country_stocks(
|
||||
}
|
||||
|
||||
|
||||
async def fetch_btc_technicals(fetcher: Fetcher) -> dict:
|
||||
"""Compute Bitcoin technical indicators from CoinGecko historical data.
|
||||
|
||||
Calculates SMA-50, SMA-200, Mayer Multiple (price/SMA200), golden/death
|
||||
cross status, and distance from all-time high.
|
||||
|
||||
Returns::
|
||||
|
||||
{"price": float, "sma_50": float, "sma_200": float,
|
||||
"mayer_multiple": float, "cross_signal": str, ...}
|
||||
"""
|
||||
url = "https://api.coingecko.com/api/v3/coins/bitcoin/market_chart"
|
||||
data = await fetcher.get_json(
|
||||
url,
|
||||
source="coingecko",
|
||||
cache_key="markets:btc_technicals",
|
||||
cache_ttl=600,
|
||||
params={"vs_currency": "usd", "days": "200", "interval": "daily"},
|
||||
)
|
||||
|
||||
if data is None or "prices" not in data:
|
||||
return {"error": "Failed to fetch BTC historical data", "source": "coingecko", "timestamp": _utc_now_iso()}
|
||||
|
||||
prices = [p[1] for p in data["prices"]]
|
||||
if len(prices) < 50:
|
||||
return {"error": "Insufficient price history", "source": "coingecko", "timestamp": _utc_now_iso()}
|
||||
|
||||
current_price = prices[-1]
|
||||
|
||||
sma_50 = sum(prices[-50:]) / 50
|
||||
sma_200 = sum(prices[-200:]) / min(len(prices), 200) if len(prices) >= 50 else None
|
||||
|
||||
mayer_multiple = round(current_price / sma_200, 4) if sma_200 and sma_200 > 0 else None
|
||||
|
||||
# Golden cross: SMA50 > SMA200, Death cross: SMA50 < SMA200
|
||||
cross_signal = "neutral"
|
||||
if sma_200 is not None:
|
||||
if sma_50 > sma_200:
|
||||
cross_signal = "golden_cross"
|
||||
elif sma_50 < sma_200:
|
||||
cross_signal = "death_cross"
|
||||
|
||||
# Distance from ATH
|
||||
ath = max(prices)
|
||||
ath_distance_pct = round(((current_price - ath) / ath) * 100, 2) if ath > 0 else None
|
||||
|
||||
# 7d and 30d price change
|
||||
change_7d = round(((current_price - prices[-8]) / prices[-8]) * 100, 2) if len(prices) >= 8 else None
|
||||
change_30d = round(((current_price - prices[-31]) / prices[-31]) * 100, 2) if len(prices) >= 31 else None
|
||||
|
||||
return {
|
||||
"price": round(current_price, 2),
|
||||
"sma_50": round(sma_50, 2),
|
||||
"sma_200": round(sma_200, 2) if sma_200 else None,
|
||||
"mayer_multiple": mayer_multiple,
|
||||
"cross_signal": cross_signal,
|
||||
"ath_in_period": round(ath, 2),
|
||||
"ath_distance_pct": ath_distance_pct,
|
||||
"change_7d_pct": change_7d,
|
||||
"change_30d_pct": change_30d,
|
||||
"data_points": len(prices),
|
||||
"source": "coingecko",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_macro_signals(fetcher: Fetcher) -> dict:
|
||||
"""Aggregate 7 macro signals into a single dashboard payload.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user