Date: 2025-10-13 Target: Sub-500ms latency for price quotes and responsive mobile UX Current Stack: FastAPI (Python) + React Native + Polygon.io
- Sequential API calls in /rate-signal: 1-7 Polygon requests per endpoint call (up to 7+ seconds)
- Duplicate HTTP clients: 3 separate httpx clients for same API without pooling
- 30-second timeout: Far too long for real-time quotes (should be 2-5s)
- No caching layer: Repeated requests for identical data
- Synchronous I/O: No async operations, blocking worker threads
- Single-threaded workers: 2 uvicorn workers but no async parallelization
| Endpoint | Current Est. | Target | Status |
|---|---|---|---|
/interpret |
300-600ms | <500ms | PASS (with optimization) |
/rate-signal (XAUUSD) |
5-8 seconds | <2s | FAIL (needs major refactor) |
/rate-signal (others) |
600-1200ms | <1s | MARGINAL (needs caching) |
/insights/{symbol} |
800-1500ms | <2s | MARGINAL (needs caching) |
Parser: ~5-15ms (regex, string operations)
Polygon Quote (via PolygonQuoteProvider): ~200-400ms
- DNS lookup: 10-30ms
- SSL handshake: 30-80ms (if not reused)
- API request: 150-250ms
- JSON parsing: 5-10ms
Calculator: ~5-10ms (arithmetic)
Response serialization: ~5-10ms
----------------------------------------------
TOTAL: 225-515ms ✅ ACCEPTABLE (barely)
Optimization potential: 150-300ms with connection pooling + caching
1. PolygonPriceService.get_current_price(): ~400-600ms
- Snapshot API call (forex): 200-400ms
- Fallback to crypto market: +200-400ms (if needed)
2. GoldSentimentAnalyzer.analyze_gold_sentiment(): 4-6 SECONDS ⚠️
- 6 sequential ETF lookups: 6 × (400-800ms) = 2.4-4.8s
* GLD ticker API: ~600ms
* IAU ticker API: ~600ms
* GDX ticker API: ~600ms
* GDXJ ticker API: ~600ms
* NUGT ticker API: ~600ms
* JNUG ticker API: ~600ms
- Sentiment calculation: ~5-10ms
3. Confidence calculation: ~1-3ms
4. Response serialization: ~5-10ms
----------------------------------------------
TOTAL: 5-8 SECONDS ❌ UNACCEPTABLE
1. PolygonPriceService.get_current_price(): ~400-800ms
2. Price-based analysis: ~2-5ms
3. Response serialization: ~5-10ms
----------------------------------------------
TOTAL: 407-815ms ⚠️ MARGINAL
1. IndicatorService.snapshot(): ~800-1500ms
- Fetch 120 bars of 15-min aggregates: 600-1200ms
- Calculate SMA(20): ~10-20ms
- Calculate EMA(50): ~20-40ms
- Calculate RSI(14): ~15-30ms
- Calculate ATR(14): ~10-20ms
- Trend determination: ~2-5ms
2. Response serialization: ~5-10ms
----------------------------------------------
TOTAL: 805-1505ms ⚠️ MARGINAL
Current State:
# polygon.py (Line 33)
self._client = client or httpx.Client(base_url=self._base_url, timeout=5.0)
# polygon_price.py (Line 52)
self.client = httpx.Client(base_url=self.base_url, timeout=30.0)
# indicators.py (Line 101)
self._client = client or httpx.Client(base_url=base_url.rstrip("/"), timeout=5.0)
# gold_sentiment.py (Line 60)
self.client = httpx.Client(base_url=self.base_url, timeout=30.0)Issues:
- 4 separate HTTP clients for same API
- No connection reuse between requests
- TCP handshake + SSL negotiation on every request (~40-110ms overhead)
- No connection pooling configuration visible
- Inconsistent timeout values (5s vs 30s)
Impact: +40-110ms per request, +160-440ms for gold sentiment (4 API calls)
File: /home/claude-dev/repos/tradecalc/src/tradecalc/services/gold_sentiment.py
# Lines 146-156: Sequential loop
for category, tickers in proxy_config.items():
for ticker in tickers:
data = self._get_proxy_short_data(ticker) # BLOCKING6 ETFs called sequentially:
- Physical: GLD (600ms), IAU (600ms) = 1.2s
- Miners: GDX (600ms), GDXJ (600ms) = 1.2s
- Leveraged: NUGT (600ms), JNUG (600ms) = 1.2s
- Total: 3.6s (could be 600ms if parallel)
Impact: 3 seconds wasted on serialization
Current State: All services use synchronous httpx.Client
# polygon_price.py (Line 89)
response = self.client.get(url, params=params) # BLOCKING
# gold_sentiment.py (Line 91)
response = self.client.get(url, params=params) # BLOCKINGIssues:
- Blocks uvicorn worker thread during I/O
- Cannot handle concurrent requests efficiently
- 2 workers = max 2 concurrent requests served well
- No request multiplexing
Impact: Poor concurrency, worker starvation
Missing caching opportunities:
| Data Type | Ideal TTL | Cache Hit Savings | Current State |
|---|---|---|---|
| Price quotes | 1-5 seconds | 300-600ms | None ❌ |
| Gold sentiment | 1-5 minutes | 4-6 seconds | None ❌ |
| Indicator snapshots | 15 minutes | 800-1500ms | None ❌ |
| ETF ticker metadata | 1 hour | 600ms per ticker | None ❌ |
| Symbol mappings | Indefinite | 5-10ms | None ❌ |
Example: 10 mobile users request XAUUSD quote within 2 seconds:
- Without cache: 10 × 600ms = 6 seconds of API load
- With cache: 600ms (first) + 9 × ~5ms = 645ms total
Impact:
- Unnecessary API load
- Increased latency
- Higher Polygon.io costs
- Poor mobile UX on slow connections
Files:
/home/claude-dev/repos/tradecalc/src/tradecalc/services/polygon_price.py(Line 41)/home/claude-dev/repos/tradecalc/src/tradecalc/services/gold_sentiment.py(Line 49)
timeout: float = 30.0 # TOO LONGIssues:
- Users will abandon requests after 3-5 seconds
- Masks slow API responses
- Prevents quick failure detection
- Mobile app shows loading spinner for 30s on error
Recommended: 2-5 seconds max for real-time quotes
Files:
polygon_price.py(Lines 169-178)gold_sentiment.py(Lines 203-212)
# Singleton instance
_price_service = None
def get_polygon_price_service() -> PolygonPriceService:
global _price_service
if _price_service is None:
_price_service = PolygonPriceService()
return _price_serviceIssues:
- Not thread-safe (race condition on initialization)
- Shared mutable state across uvicorn workers
- HTTP client shared across threads
- No explicit connection pool management
Impact: Potential crashes, connection leaks, undefined behavior
File: /home/claude-dev/repos/tradecalc/mobile/src/services/api.ts
// Lines 36-44: No request deduplication
export async function interpretOrder(body: InterpretRequest) {
const response = await axios.post<OrderCalculation>(...);
return response.data;
}Missing optimizations:
- No request deduplication: Multiple taps = multiple API calls
- No request cancellation: Navigation away doesn't abort request
- No optimistic UI: Always waits for server
- No retry logic: Network errors require manual retry
- No request batching: Each action = separate HTTP request
Signal Rating Screen (SignalRatingScreen.tsx):
- Uses ScrollView instead of FlatList (less efficient)
- No memoization of expensive calculations
- Re-renders entire component on state change
- No skeleton loading states
Impact: CRITICAL - Makes /rate-signal unusable for XAUUSD
File: gold_sentiment.py lines 146-156
Fix Effort: Medium
User Impact: High (80% of signal rating usage is gold)
Solution: Parallelize ETF lookups with async
Impact: HIGH - Affects all endpoints, increases API costs Fix Effort: Medium User Impact: High (every request is slower than needed)
Solution: Redis or in-memory cache with short TTLs
Impact: MEDIUM - Consistent latency overhead File: All service files Fix Effort: Low User Impact: Medium (adds up with multiple requests)
Solution: Single shared httpx.AsyncClient with connection pool
# Change from 30.0 to 3.0 in polygon_price.py and gold_sentiment.py
timeout: float = 3.0 # Fast failure for better UXImpact: Faster error detection, better mobile UX Effort: 1 line change per file
# In api.py, create shared client at startup
import httpx
_http_client = httpx.Client(
timeout=3.0,
limits=httpx.Limits(
max_connections=20,
max_keepalive_connections=10,
keepalive_expiry=30.0
)
)
# Pass to all services
_quote_provider = PolygonQuoteProvider(api_key=api_key, client=_http_client)
_price_service = PolygonPriceService(api_key=api_key, client=_http_client)Impact: -40-110ms per request, better concurrency Effort: 10 minutes
from functools import lru_cache
from datetime import datetime, timedelta
_cache = {}
_cache_ttl = {}
def cached_get_price(symbol: str, ttl_seconds=5):
now = datetime.now()
cache_key = f"price:{symbol}"
if cache_key in _cache:
if now < _cache_ttl[cache_key]:
return _cache[cache_key]
# Fetch from API
price = price_service.get_current_price(symbol)
_cache[cache_key] = price
_cache_ttl[cache_key] = now + timedelta(seconds=ttl_seconds)
return priceImpact: -300-600ms on cache hits, reduced API costs Effort: 30 minutes
// In api.ts
const pendingRequests = new Map<string, Promise<any>>();
export async function rateSignal(body: RateSignalRequest) {
const key = `${body.symbol}-${body.action}`;
if (pendingRequests.has(key)) {
return pendingRequests.get(key);
}
const promise = axios.post(...);
pendingRequests.set(key, promise);
try {
const result = await promise;
return result;
} finally {
pendingRequests.delete(key);
}
}Impact: Prevents duplicate requests, better UX Effort: 15 minutes
| Data Type | TTL | Eviction | Size Limit |
|---|---|---|---|
| Price quotes | 2-5 seconds | Time-based | 100 symbols |
| Gold sentiment | 5 minutes | Time-based | 10 analyses |
| Indicator snapshots | 15 minutes | Time-based | 20 symbols |
| Symbol metadata | 1 hour | Time-based | 500 symbols |
Implementation:
from cachetools import TTLCache
from datetime import timedelta
price_cache = TTLCache(maxsize=100, ttl=5.0) # 5 seconds
sentiment_cache = TTLCache(maxsize=10, ttl=300.0) # 5 minutes
indicator_cache = TTLCache(maxsize=20, ttl=900.0) # 15 minutesWhen to add: If traffic exceeds 100 req/min or multiple backend instances needed
Benefits:
- Shared cache across uvicorn workers
- Persistent across restarts
- Pub/sub for real-time invalidation
- Better eviction policies
Redis Key Structure:
tradecalc:price:{symbol} TTL=5s
tradecalc:sentiment:{symbol} TTL=300s
tradecalc:indicators:{symbol} TTL=900s
tradecalc:metadata:{symbol} TTL=3600s
File: polygon_price.py
import httpx
class PolygonPriceService:
def __init__(self, ...):
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=3.0,
limits=httpx.Limits(
max_connections=20,
max_keepalive_connections=10
)
)
async def get_current_price(self, symbol: str) -> Optional[dict]:
# Change from self.client.get() to await self.client.get()
response = await self.client.get(url, params=params)
...File: gold_sentiment.py
import asyncio
class GoldSentimentAnalyzer:
async def _get_proxy_short_data(self, ticker: str) -> Optional[dict]:
response = await self.client.get(url, params=params)
...
async def analyze_gold_sentiment(self, symbol: str) -> dict:
# Parallelize ETF lookups
tasks = []
for category, tickers in proxy_config.items():
for ticker in tickers:
tasks.append(self._get_proxy_short_data(ticker))
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results...Impact:
- Gold sentiment: 4-6s → 600-800ms (5-7x faster)
- Better concurrency handling
- Non-blocking I/O
File: api.py
@app.post("/rate-signal", response_model=SignalRating)
async def rate_signal(request: RateSignalRequest) -> SignalRating: # Add async
# Get current market price
price_service = get_polygon_price_service()
price_data = await price_service.get_current_price(symbol) # Add await
# Gold sentiment analysis
if symbol == 'XAUUSD':
gold_analyzer = get_gold_sentiment_analyzer()
sentiment_data = await gold_analyzer.analyze_gold_sentiment(symbol) # Add await
...File: ecosystem.mobile_calc.config.js
args: '--host 127.0.0.1 --port 8100 --workers 2'
instances: 1
exec_mode: 'fork'Analysis:
- 2 uvicorn workers = 2 threads handling requests
- Fork mode (not cluster)
- Each worker blocks on synchronous I/O
- Theoretical max: ~2 concurrent requests handled well
args: '--host 127.0.0.1 --port 8100 --workers 4 --loop asyncio'
instances: 1
exec_mode: 'fork'
max_memory_restart: '768M' // Increased for connection poolsWith async + connection pooling:
- 4 workers with async I/O
- Each worker handles 50-100+ concurrent requests
- Theoretical max: 200-400 concurrent requests
- Better resource utilization
Current: All fields sent even when unused
Optimization: Only send necessary fields
// Mobile: Only send non-null values
const payload = {
symbol: body.symbol,
action: body.action,
...(body.entryPrice && { entry_price: body.entryPrice }),
...(body.stopLoss && { stop_loss: body.stopLoss }),
...(body.takeProfit && { take_profit: body.takeProfit })
};Savings: 10-30 bytes per request (minimal but good practice)
Current: Full market_data and sentiment objects even when not used
Optimization: Add ?include=sentiment,market_data query param
@app.post("/rate-signal")
async def rate_signal(
request: RateSignalRequest,
include: Optional[str] = None
):
fields = set(include.split(',')) if include else {'all'}
response = SignalRating(...)
if 'sentiment' not in fields and 'all' not in fields:
response.sentiment = None
if 'market_data' not in fields and 'all' not in fields:
response.market_data = None
return responseSavings: 200-500 bytes per response on mobile (5-10% faster over slow connections)
Current: Likely HTTP/1.1 (default for uvicorn)
Recommendation: Enable HTTP/2 for request multiplexing
# In PM2 config
args: '--host 127.0.0.1 --port 8100 --workers 4 --http h2'NGINX configuration:
location /api {
proxy_pass http://127.0.0.1:8100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
http2_push_preload on;
}Benefits:
- Multiple requests over single connection
- Header compression
- Server push (future)
// In SignalRatingScreen.tsx
import { useEffect, useRef } from 'react';
export default function SignalRatingScreen() {
const abortControllerRef = useRef<AbortController | null>(null);
const handleRateSignal = async () => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const result = await rateSignal(request, {
signal: abortControllerRef.current.signal
});
setRating(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError('Failed to rate signal');
}
}
};
useEffect(() => {
return () => {
// Cleanup on unmount
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
}const handleRateSignal = async () => {
// Show immediate feedback
setLoading(true);
setRating({
...request,
current_price: 0,
confidence: 0.5,
recommendation: 'neutral',
reason: 'Analyzing...'
} as SignalRating);
try {
const result = await rateSignal(request);
setRating(result); // Replace optimistic with real data
} catch (err) {
setRating(null); // Rollback on error
setError('Failed to rate signal');
} finally {
setLoading(false);
}
};{loading && !rating && (
<View style={styles.skeleton}>
<SkeletonLoader width="100%" height={60} />
<SkeletonLoader width="80%" height={40} />
<SkeletonLoader width="90%" height={120} />
</View>
)}Impact: Perceived performance improvement (feels 30-50% faster)
| Endpoint | Current | Target | Post-Optimization | Method |
|---|---|---|---|---|
/interpret |
300-600ms | <500ms | 200-350ms | Connection pooling + cache |
/rate-signal (XAUUSD) |
5-8s | <2s | 800-1200ms | Async parallelization + cache |
/rate-signal (others) |
600-1200ms | <1s | 300-600ms | Connection pooling + cache |
/insights/{symbol} |
800-1500ms | <2s | 400-800ms | Cache + connection pooling |
- Reduce timeouts from 30s to 3s
- Configure HTTP connection pooling
- Add simple in-memory cache for price quotes (TTL=5s)
- Mobile request deduplication
Expected Improvement: 30-40% latency reduction
- Convert PolygonPriceService to async
- Convert GoldSentimentAnalyzer to async with parallel ETF calls
- Convert IndicatorService to async
- Update FastAPI endpoints to async handlers
- Test under load
Expected Improvement: 70-80% latency reduction for gold sentiment
- Implement TTLCache for all price data
- Add cache invalidation logic
- Cache gold sentiment analysis
- Cache indicator snapshots
- Add cache hit/miss metrics
Expected Improvement: 50-90% latency reduction on cache hits
- Request cancellation on navigation
- Optimistic UI updates
- Skeleton loading states
- Retry logic with exponential backoff
- Error boundary improvements
Expected Improvement: Perceived performance +40-60%
- Set up Redis instance
- Implement Redis caching layer
- Add cache warming strategy
- Set up monitoring and metrics
- Load testing
Expected Improvement: Better scaling for multiple backend instances
import time
from contextlib import contextmanager
@contextmanager
def track_duration(operation: str):
start = time.perf_counter()
try:
yield
finally:
duration = time.perf_counter() - start
logger.info(f"{operation} took {duration*1000:.1f}ms")
# Usage in endpoints
@app.post("/rate-signal")
async def rate_signal(request: RateSignalRequest) -> SignalRating:
with track_duration("rate_signal_total"):
with track_duration("fetch_price"):
price_data = await price_service.get_current_price(symbol)
if symbol == 'XAUUSD':
with track_duration("gold_sentiment"):
sentiment_data = await gold_analyzer.analyze_gold_sentiment(symbol)- Endpoint latency (p50, p95, p99)
- Polygon API latency (by endpoint)
- Cache hit rate (%)
- Error rate (%)
- Request rate (req/min)
- Concurrent requests (active)
- Worker utilization (%)
Scenario 1: Baseline (Current)
# Use k6 or locust
k6 run --vus 10 --duration 1m load-test.js
# Endpoints to test:
# - POST /interpret (10 req/s)
# - POST /rate-signal with XAUUSD (2 req/s)
# - GET /insights/XAUUSD (5 req/s)Expected Results:
- p95 latency: 2-8 seconds
- Error rate: <1%
- Max throughput: ~15-20 req/s
Scenario 2: Post-Optimization
k6 run --vus 50 --duration 1m load-test.js
# Same endpoints, higher loadExpected Results:
- p95 latency: 500-1500ms
- Error rate: <0.5%
- Max throughput: 100-150 req/s
Rate Signal (XAUUSD): 7 API calls per request
- 1 price snapshot
- 6 ETF ticker lookups
At 100 requests/day:
- 700 API calls/day
- ~21,000 API calls/month
Polygon.io Pricing (Starter Plan: $199/mo):
- 5 API calls/second limit
- Unlimited calls per month
Cache Hit Rate: Assume 60% (conservative)
At 100 requests/day with cache:
- 40 requests miss cache = 280 API calls/day
- 8,400 API calls/month
- Savings: 60% reduction in API load
At 1000 requests/day with cache:
- 400 requests miss cache = 2,800 API calls/day
- 84,000 API calls/month
- Still well within Starter plan limits
Before:
def __init__(self, api_key: Optional[str] = None, *, timeout: float = 30.0):
self.client = httpx.Client(base_url=self.base_url, timeout=timeout)
def get_current_price(self, symbol: str) -> Optional[dict]:
response = self.client.get(url, params=params)After:
def __init__(
self,
api_key: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
timeout: float = 3.0
):
self.client = client or httpx.AsyncClient(
base_url=self.base_url,
timeout=timeout,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10)
)
async def get_current_price(self, symbol: str) -> Optional[dict]:
response = await self.client.get(url, params=params)Before:
for category, tickers in proxy_config.items():
for ticker in tickers:
data = self._get_proxy_short_data(ticker)
if data:
proxy_data[ticker] = dataAfter:
import asyncio
# Collect all tasks
tasks = []
ticker_list = []
for category, tickers in proxy_config.items():
for ticker in tickers:
tasks.append(self._get_proxy_short_data(ticker))
ticker_list.append((ticker, category))
# Execute in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for (ticker, category), data in zip(ticker_list, results):
if data and not isinstance(data, Exception):
proxy_data[ticker] = {**data, 'category': category}Add at startup:
import httpx
from cachetools import TTLCache
# Shared HTTP client
_http_client = httpx.AsyncClient(
timeout=3.0,
limits=httpx.Limits(
max_connections=20,
max_keepalive_connections=10,
keepalive_expiry=30.0
)
)
# Simple caches
_price_cache = TTLCache(maxsize=100, ttl=5.0)
_sentiment_cache = TTLCache(maxsize=10, ttl=300.0)
_indicator_cache = TTLCache(maxsize=20, ttl=900.0)
@app.on_event("startup")
async def startup():
logger.info("Starting up with connection pooling enabled")
@app.on_event("shutdown")
async def shutdown():
await _http_client.aclose()- ✅ Reduce timeouts (easy rollback)
- ✅ Add in-memory caching (feature flag)
- ✅ Connection pooling (standard practice)
- ✅ Mobile request deduplication (isolated)
⚠️ Async conversion (requires thorough testing)⚠️ Parallel gold sentiment (error handling complexity)⚠️ Shared HTTP client (thread safety concerns)
- 🔴 Redis integration (new infrastructure dependency)
- 🔴 HTTP/2 (potential NGINX compatibility issues)
- 🔴 Optimistic UI (can confuse users if not well-designed)
| Metric | Value |
|---|---|
/rate-signal (XAUUSD) p95 |
7-9 seconds |
/rate-signal (others) p95 |
1-1.5 seconds |
/interpret p95 |
500-800ms |
| Cache hit rate | 0% |
| Polygon API calls/day | 700 (per 100 requests) |
| User complaints | "Too slow" |
| Metric | Value | Improvement |
|---|---|---|
/rate-signal (XAUUSD) p95 |
1-1.5 seconds | 82% faster |
/rate-signal (others) p95 |
400-700ms | 50% faster |
/interpret p95 |
250-400ms | 40% faster |
| Cache hit rate | 50-70% | New metric |
| Polygon API calls/day | 250 (per 100 requests) | 64% reduction |
| User feedback | "Much faster!" | Subjective improvement |
The TradeCalc application has significant performance optimization opportunities, particularly in the /rate-signal endpoint for gold (XAUUSD) which currently takes 5-8 seconds due to sequential API calls.
Critical Path Forward:
- Immediate (Phase 1): Apply quick wins for 30-40% improvement
- Next Sprint (Phase 2): Async conversion for 70-80% improvement on gold
- Following Sprint (Phase 3): Full caching layer for 50-90% cache hit improvement
Investment vs Return:
- Phase 1+2: ~8 hours of work → 5-7x faster gold signals
- Phase 3: +3 hours → 50-90% fewer API calls
- Phase 4: +4 hours → 40-60% better perceived performance
The most impactful change is parallelizing gold sentiment analysis (Phase 2), which alone reduces the critical path from 5-8 seconds to 800-1200ms.
Files Referenced:
/home/claude-dev/repos/tradecalc/src/tradecalc/services/api.py/home/claude-dev/repos/tradecalc/src/tradecalc/services/polygon.py/home/claude-dev/repos/tradecalc/src/tradecalc/services/polygon_price.py/home/claude-dev/repos/tradecalc/src/tradecalc/services/gold_sentiment.py/home/claude-dev/repos/tradecalc/src/tradecalc/services/indicators.py/home/claude-dev/repos/tradecalc/mobile/src/services/api.ts/home/claude-dev/repos/tradecalc/mobile/src/screens/SignalRatingScreen.tsx/home/claude-dev/repos/tradecalc/ecosystem.mobile_calc.config.js