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
+18
View File
@@ -717,6 +717,24 @@ export function useTrendProjection(trendId: string | undefined) {
return useGet<TrendProjection>(['trend-projection', trendId], 'query', `/api/trends/${trendId}/projection`, !!trendId); return useGet<TrendProjection>(['trend-projection', trendId], 'query', `/api/trends/${trendId}/projection`, !!trendId);
} }
// ---------------------------------------------------------------------------
// System: Rate Limits
// ---------------------------------------------------------------------------
export interface RateLimitInfo {
polygon_global_limit: number;
market_api: {
rate_per_minute: number;
cadence_seconds: number;
max_tickers_per_cycle: number;
active_sources: number;
};
}
export function useRateLimits() {
return useGet<RateLimitInfo>(['rate-limits'], 'query', '/api/system/rate-limits');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Macro: Admin Toggle (Task 17.6) // Macro: Admin Toggle (Task 17.6)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+45 -4
View File
@@ -1,9 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useWatchlists, useWatchlistMembers, useCreateWatchlist } from '../api/hooks'; import { useWatchlists, useWatchlistMembers, useCreateWatchlist, useRateLimits } from '../api/hooks';
import type { Company } from '../api/hooks';
import { LoadingSpinner, Card } from '../components/ui'; import { LoadingSpinner, Card } from '../components/ui';
import { AlertTriangle } from 'lucide-react';
export function WatchlistsPage() { export function WatchlistsPage() {
const { data: watchlists, isLoading } = useWatchlists(); const { data: watchlists, isLoading } = useWatchlists();
const { data: rateLimits } = useRateLimits();
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -34,21 +37,44 @@ export function WatchlistsPage() {
))} ))}
</div> </div>
{selected && <WatchlistMembers watchlistId={selected} />} {selected && <WatchlistMembers watchlistId={selected} rateLimits={rateLimits} />}
</div> </div>
); );
} }
function WatchlistMembers({ watchlistId }: { watchlistId: string }) { interface RateLimitInfo {
polygon_global_limit: number;
market_api: {
rate_per_minute: number;
cadence_seconds: number;
max_tickers_per_cycle: number;
active_sources: number;
};
}
function WatchlistMembers({ watchlistId, rateLimits }: { watchlistId: string; rateLimits?: RateLimitInfo }) {
const { data: members, isLoading } = useWatchlistMembers(watchlistId); const { data: members, isLoading } = useWatchlistMembers(watchlistId);
if (isLoading) return <LoadingSpinner />; if (isLoading) return <LoadingSpinner />;
if (!members?.length) return <p className="text-sm text-gray-500">No members in this watchlist</p>; if (!members?.length) return <p className="text-sm text-gray-500">No members in this watchlist</p>;
const maxTickers = rateLimits?.market_api.max_tickers_per_cycle;
const activeSources = rateLimits?.market_api.active_sources ?? 0;
const cadenceSec = rateLimits?.market_api.cadence_seconds ?? 300;
const cadenceMin = Math.round(cadenceSec / 60);
const overCapacity = maxTickers != null && activeSources > maxTickers;
return ( return (
<Card> <Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Members</h2> <h2 className="mb-2 text-sm font-medium text-gray-400">Members</h2>
{overCapacity && (
<RateLimitWarning
activeSources={activeSources}
maxTickers={maxTickers!}
cadenceMin={cadenceMin}
/>
)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{members.map((m) => ( {members.map((m: Company) => (
<span key={m.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300"> <span key={m.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
{m.ticker} {m.legal_name} {m.ticker} {m.legal_name}
</span> </span>
@@ -58,6 +84,21 @@ function WatchlistMembers({ watchlistId }: { watchlistId: string }) {
); );
} }
function RateLimitWarning({ activeSources, maxTickers, cadenceMin }: { activeSources: number; maxTickers: number; cadenceMin: number }) {
const cycleMinutes = Math.ceil(activeSources / (maxTickers / cadenceMin));
return (
<div className="mb-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" aria-hidden="true" />
<div className="text-xs text-amber-300">
<span className="font-medium">Rate limit warning:</span>{' '}
{activeSources} active tickers exceed the {maxTickers}-ticker refresh capacity
per {cadenceMin}-minute cycle. Full refresh will take ~{cycleMinutes} minutes
instead of {cadenceMin}. Remove tickers or increase the rate limit to improve freshness.
</div>
</div>
);
}
function CreateWatchlistForm({ onClose }: { onClose: () => void }) { function CreateWatchlistForm({ onClose }: { onClose: () => void }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [desc, setDesc] = useState(''); const [desc, setDesc] = useState('');
+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) # 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). # Default polling cadences by source class (seconds).
# Individual sources can override via config.polling_interval_seconds. # Individual sources can override via config.polling_interval_seconds.
DEFAULT_CADENCES: dict[str, int] = { DEFAULT_CADENCES: dict[str, int] = {
"market_api": 900, "market_api": 300,
"news_api": 300, "news_api": 300,
"filings_api": 3600, "filings_api": 3600,
"web_scrape": 1800, "web_scrape": 1800,
@@ -55,7 +55,7 @@ DEFAULT_CADENCES: dict[str, int] = {
# Default rate limits per source type (requests per minute) # Default rate limits per source type (requests per minute)
DEFAULT_RATE_LIMITS: dict[str, int] = { DEFAULT_RATE_LIMITS: dict[str, int] = {
"market_api": 5, "market_api": 20,
"news_api": 20, "news_api": 20,
"filings_api": 10, "filings_api": 10,
"web_scrape": 10, "web_scrape": 10,
@@ -63,6 +63,12 @@ DEFAULT_RATE_LIMITS: dict[str, int] = {
"macro_news": 10, "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) # How long to wait before retrying a failed source (seconds)
DEFAULT_BACKOFF_BASE: int = 60 DEFAULT_BACKOFF_BASE: int = 60
MAX_BACKOFF: int = 3600 MAX_BACKOFF: int = 3600
@@ -173,15 +179,35 @@ async def check_rate_limit(
) -> bool: ) -> bool:
"""Check whether the source type is within its rate limit window. """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. Returns True if the request is allowed, False if rate-limited.
""" """
limit = max_per_minute or DEFAULT_RATE_LIMITS.get(source_type, 30) limit = max_per_minute or DEFAULT_RATE_LIMITS.get(source_type, 30)
window = now.strftime("%Y%m%d%H%M") window = now.strftime("%Y%m%d%H%M")
# Per-source-type check
key = rate_limit_key(source_type, window) key = rate_limit_key(source_type, window)
count = await rds.incr(key) count = await rds.incr(key)
if count == 1: if count == 1:
await rds.expire(key, 120) 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]: async def fetch_active_sources(pool: asyncpg.Pool) -> list[asyncpg.Record]:
+2 -2
View File
@@ -13,7 +13,7 @@ from services.scheduler.app import (
class TestGetCadenceForSource: class TestGetCadenceForSource:
def test_default_cadence_market_api(self): def test_default_cadence_market_api(self):
assert get_cadence_for_source("market_api", None) == 60 assert get_cadence_for_source("market_api", None) == 300
def test_default_cadence_news_api(self): def test_default_cadence_news_api(self):
assert get_cadence_for_source("news_api", {}) == 300 assert get_cadence_for_source("news_api", {}) == 300
@@ -57,7 +57,7 @@ class TestIsSourceDue:
assert not is_source_due("market_api", None, last, "completed", 0, None, self._now()) assert not is_source_due("market_api", None, last, "completed", 0, None, self._now())
def test_completed_past_cadence_is_due(self): def test_completed_past_cadence_is_due(self):
last = self._now() - timedelta(seconds=120) last = self._now() - timedelta(seconds=400)
assert is_source_due("market_api", None, last, "completed", 0, None, self._now()) assert is_source_due("market_api", None, last, "completed", 0, None, self._now())
def test_running_not_due(self): def test_running_not_due(self):