feat: raise market_api rate to 20/min, add global Polygon cap at 45/min, add rate-limit API + watchlist warning

This commit is contained in:
Celes Renata
2026-04-16 07:26:10 +00:00
parent 0b3ab4ed90
commit 0ee7f26633
5 changed files with 148 additions and 9 deletions
+54
View File
@@ -1534,6 +1534,60 @@ async def get_source_coverage_gaps():
}
# ---------------------------------------------------------------------------
# System: Rate Limit Info
# ---------------------------------------------------------------------------
@app.get("/api/system/rate-limits")
async def get_rate_limits():
"""Return current rate limit configuration and usage.
Exposes the scheduler's rate limits so the frontend can calculate
how many tickers can be refreshed within the configured cadence.
"""
from services.scheduler.app import (
DEFAULT_CADENCES,
DEFAULT_RATE_LIMITS,
POLYGON_GLOBAL_RATE_LIMIT,
POLYGON_SOURCE_TYPES,
)
# Count active market_api sources to report current load
market_count = await pool.fetchval(
"SELECT count(*) FROM sources WHERE active = TRUE AND source_type = 'market_api'"
)
news_count = await pool.fetchval(
"SELECT count(*) FROM sources WHERE active = TRUE AND source_type = 'news_api'"
)
market_cadence = DEFAULT_CADENCES.get("market_api", 300)
market_rate = DEFAULT_RATE_LIMITS.get("market_api", 20)
# How many tickers can we refresh within one cadence window?
# cadence_minutes * rate_per_minute = max tickers per cycle
cadence_minutes = market_cadence / 60
max_tickers_per_cycle = int(cadence_minutes * market_rate)
return {
"polygon_global_limit": POLYGON_GLOBAL_RATE_LIMIT,
"polygon_source_types": sorted(POLYGON_SOURCE_TYPES),
"per_type_limits": DEFAULT_RATE_LIMITS,
"cadences_seconds": DEFAULT_CADENCES,
"market_api": {
"rate_per_minute": market_rate,
"cadence_seconds": market_cadence,
"max_tickers_per_cycle": max_tickers_per_cycle,
"active_sources": market_count,
},
"news_api": {
"rate_per_minute": DEFAULT_RATE_LIMITS.get("news_api", 20),
"cadence_seconds": DEFAULT_CADENCES.get("news_api", 300),
"active_sources": news_count,
},
}
# ---------------------------------------------------------------------------
# Analytics: Trino SQL Proxy (Requirement 10.1, 10.3, 13.7)
# ---------------------------------------------------------------------------
+29 -3
View File
@@ -45,7 +45,7 @@ def _ensure_dict(val: Any) -> Optional[dict]:
# Default polling cadences by source class (seconds).
# Individual sources can override via config.polling_interval_seconds.
DEFAULT_CADENCES: dict[str, int] = {
"market_api": 900,
"market_api": 300,
"news_api": 300,
"filings_api": 3600,
"web_scrape": 1800,
@@ -55,7 +55,7 @@ DEFAULT_CADENCES: dict[str, int] = {
# Default rate limits per source type (requests per minute)
DEFAULT_RATE_LIMITS: dict[str, int] = {
"market_api": 5,
"market_api": 20,
"news_api": 20,
"filings_api": 10,
"web_scrape": 10,
@@ -63,6 +63,12 @@ DEFAULT_RATE_LIMITS: dict[str, int] = {
"macro_news": 10,
}
# Global rate limit across all Polygon-backed source types (requests per minute).
# market_api + news_api share a single Polygon API key, so we cap the combined
# throughput to stay safely under the plan limit.
POLYGON_SOURCE_TYPES: set[str] = {"market_api", "news_api"}
POLYGON_GLOBAL_RATE_LIMIT: int = 45
# How long to wait before retrying a failed source (seconds)
DEFAULT_BACKOFF_BASE: int = 60
MAX_BACKOFF: int = 3600
@@ -173,15 +179,35 @@ async def check_rate_limit(
) -> bool:
"""Check whether the source type is within its rate limit window.
Enforces two limits:
1. Per-source-type limit (e.g. market_api: 20/min)
2. Global Polygon limit across all Polygon-backed types (45/min combined)
Returns True if the request is allowed, False if rate-limited.
"""
limit = max_per_minute or DEFAULT_RATE_LIMITS.get(source_type, 30)
window = now.strftime("%Y%m%d%H%M")
# Per-source-type check
key = rate_limit_key(source_type, window)
count = await rds.incr(key)
if count == 1:
await rds.expire(key, 120)
return count <= limit
if count > limit:
return False
# Global Polygon check for source types that share the Polygon API key
if source_type in POLYGON_SOURCE_TYPES:
global_key = rate_limit_key("_polygon_global", window)
global_count = await rds.incr(global_key)
if global_count == 1:
await rds.expire(global_key, 120)
if global_count > POLYGON_GLOBAL_RATE_LIMIT:
# Roll back the per-type counter since we won't actually make the call
await rds.decr(key)
return False
return True
async def fetch_active_sources(pool: asyncpg.Pool) -> list[asyncpg.Record]: