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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user