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