Skip to content

Latest commit

 

History

History
1011 lines (807 loc) · 24.2 KB

File metadata and controls

1011 lines (807 loc) · 24.2 KB

TradeCalc Implementation Guide

Status: Ready for implementation Estimated Time: 8-12 hours Priority: HIGH - Contains critical production fixes


Quick Start

This document contains all code changes needed to fix critical issues and prepare for iOS/Android deployment. Follow the order presented for optimal results.


Phase 1: Backend Critical Fixes (3-4 hours)

1. Update Dependencies

File: pyproject.toml

Add to dependencies:

dependencies = [
    "fastapi>=0.111",
    "uvicorn>=0.29",
    "pydantic>=2.6",
    "python-dotenv>=1.0",
    "httpx>=0.27",
    "cachetools>=5.3",  # ADD THIS LINE
]

Install:

cd /home/claude-dev/repos/tradecalc
source .venv/bin/activate
pip install cachetools
pip install -e ".[dev]"

2. Fix polygon_price.py - Thread Safety + Async

File: src/tradecalc/services/polygon_price.py

Changes needed:

  1. Convert httpx.Clienthttpx.AsyncClient
  2. Add thread-safe singleton pattern
  3. Convert all methods to async def
  4. Reduce timeout from 30s → 3s

Key code sections to update:

# Line 41: Change timeout
def __init__(
    self,
    api_key: Optional[str] = None,
    *,
    base_url: str = "https://api.polygon.io",
    timeout: float = 3.0,  # CHANGE FROM 30.0
) -> None:

# Line 52: Change to AsyncClient
    self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)

# Line 58: Add async
async def get_current_price(self, symbol: str) -> Optional[dict]:

# Line 85-90: Change to async get
    response = await self.client.get(url, params=params)

# Line 127-130: Change to async get
    response = await self.client.get(url, params=params)

# Line 164-166: Add async close
async def close(self) -> None:
    """Close HTTP client"""
    await self.client.aclose()

# Lines 169-178: Thread-safe singleton
import threading

_price_service: Optional[PolygonPriceService] = None
_price_service_lock = threading.Lock()

def get_polygon_price_service() -> PolygonPriceService:
    """Get or create thread-safe singleton price service"""
    global _price_service
    if _price_service is None:
        with _price_service_lock:
            if _price_service is None:
                _price_service = PolygonPriceService()
    return _price_service

3. Fix gold_sentiment.py - Thread Safety + Async + Parallel

File: src/tradecalc/services/gold_sentiment.py

Major changes:

  1. Convert to AsyncClient
  2. Parallelize ETF requests (6x speedup!)
  3. Add thread-safe singleton
  4. Add timeout (10s total)
  5. Reduce individual timeout 30s → 3s

Key code to replace:

# Line 49: Change timeout
def __init__(
    self,
    api_key: Optional[str] = None,
    *,
    base_url: str = "https://api.polygon.io",
    timeout: float = 3.0,  # CHANGE FROM 30.0
) -> None:

# Line 60: Change to AsyncClient
    self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)

# Line 80: Add async
async def _get_proxy_short_data(self, ticker: str) -> Optional[dict]:

# Line 91: Change to async get
    response = await self.client.get(url, params=params)

# Line 116: Add async
async def analyze_gold_sentiment(self, symbol: str = 'XAUUSD') -> dict:

# Lines 146-161: CRITICAL - Parallelize requests
    # OLD CODE (sequential):
    for ticker in tickers:
        data = self._get_proxy_short_data(ticker)

    # NEW CODE (parallel):
    import asyncio

    for category, tickers in proxy_config.items():
        # Fetch all tickers in this category in parallel
        tasks = [self._get_proxy_short_data(ticker) for ticker in tickers]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        for ticker, data in zip(tickers, results):
            if isinstance(data, Exception):
                continue  # Skip errors
            if data:
                proxy_data[ticker] = {
                    **data,
                    'category': category
                }
                category_squeeze_scores.append(data['squeeze_potential'])

# Line 198: Add async close
async def close(self) -> None:
    """Close HTTP client"""
    await self.client.aclose()

# Lines 203-212: Thread-safe singleton
import threading

_gold_analyzer: Optional[GoldSentimentAnalyzer] = None
_gold_analyzer_lock = threading.Lock()

def get_gold_sentiment_analyzer() -> GoldSentimentAnalyzer:
    """Get or create thread-safe singleton analyzer"""
    global _gold_analyzer
    if _gold_analyzer is None:
        with _gold_analyzer_lock:
            if _gold_analyzer is None:
                _gold_analyzer = GoldSentimentAnalyzer()
    return _gold_analyzer

This is the MOST CRITICAL change - it reduces XAUUSD signal rating from 5-8 seconds to 800ms!


4. Update api.py - Async + Validation + Caching + Shutdown

File: src/tradecalc/services/api.py

Major changes:

  1. Convert endpoints to async
  2. Add input validation
  3. Integrate caching
  4. Add shutdown handlers
  5. Fix division by zero

Key sections:

# Add at top with other imports
from tradecalc.services.cache import get_price_cache, get_sentiment_cache, get_all_cache_stats
from pydantic import Field, field_validator

# Line 35-56: Add validation to RateSignalRequest
class RateSignalRequest(BaseModel):
    """Request model for signal rating"""
    symbol: str = Field(..., min_length=3, max_length=10)
    action: str = Field(..., pattern="^(BUY|SELL)$")
    entry_price: Optional[float] = Field(None, gt=0)
    stop_loss: Optional[float] = Field(None, gt=0)
    take_profit: Optional[float] = Field(None, gt=0)

    @field_validator('symbol')
    @classmethod
    def validate_symbol(cls, v: str) -> str:
        """Ensure symbol is uppercase"""
        return v.upper()

# Line 65-72: Update shutdown to include new services
@app.on_event("shutdown")
async def shutdown_provider() -> None:  # ADD async
    """Cleanup all services"""
    # Original services
    close = getattr(_quote_provider, "close", None)
    if callable(close):
        if asyncio.iscoroutinefunction(close):
            await close()
        else:
            close()

    if _indicator_service and hasattr(_indicator_service, "close"):
        await _indicator_service.close()

    # NEW: Add new services
    try:
        price_service = get_polygon_price_service()
        await price_service.close()
    except Exception:
        pass

    try:
        gold_analyzer = get_gold_sentiment_analyzer()
        await gold_analyzer.close()
    except Exception:
        pass

# Line 74-101: Convert /interpret to async (optional but recommended)
@app.post("/interpret", response_model=OrderCalculation)
async def interpret(request: InterpretRequest) -> OrderCalculation:
    # ... existing code, just add async keyword

# Line 104-112: Convert /insights to async
@app.get("/insights/{symbol}", response_model=IndicatorSnapshot)
async def indicator_snapshot(symbol: str) -> IndicatorSnapshot:
    if _indicator_service is None:
        raise HTTPException(status_code=503, detail="Indicator service is not configured")
    try:
        snapshot = await _indicator_service.snapshot(symbol)  # ADD await
    except Exception as exc:
        raise HTTPException(status_code=502, detail=str(exc)) from exc
    return snapshot

# Line 115-244: Convert /rate-signal to async with caching
@app.post("/rate-signal", response_model=SignalRating)
async def rate_signal(request: RateSignalRequest) -> SignalRating:  # ADD async
    """Rate a trading signal with caching"""
    symbol = request.symbol.upper()
    action = request.action.upper()

    # Check price cache first
    price_cache = get_price_cache()
    cache_key_args = (symbol,)
    price_data = price_cache.get("polygon_price", *cache_key_args)

    if not price_data:
        # Cache miss - fetch from API
        try:
            price_service = get_polygon_price_service()
            price_data = await price_service.get_current_price(symbol)  # ADD await

            if not price_data:
                raise HTTPException(
                    status_code=502,
                    detail=f"Unable to fetch current price for {symbol}"
                )

            # Cache the result
            price_cache.set("polygon_price", price_data, *cache_key_args)

        except Exception as exc:
            raise HTTPException(
                status_code=502,
                detail=f"Price fetch failed: {str(exc)}"
            ) from exc

    current_price = price_data.get('mid') or price_data.get('last')
    if not current_price:
        raise HTTPException(
            status_code=502,
            detail=f"No valid price data for {symbol}"
        )

    # ... existing confidence calculation code ...

    # Gold sentiment with caching
    if symbol == 'XAUUSD':
        sentiment_cache = get_sentiment_cache()
        sentiment_data = sentiment_cache.get("gold_sentiment", symbol)

        if not sentiment_data:
            # Cache miss - analyze sentiment
            try:
                gold_analyzer = get_gold_sentiment_analyzer()
                sentiment_data = await gold_analyzer.analyze_gold_sentiment(symbol)  # ADD await
                sentiment_cache.set("gold_sentiment", sentiment_data, symbol)
            except Exception as exc:
                logger.warning(f"Gold sentiment analysis failed: {exc}")
                reasons.append("Gold sentiment unavailable")

        # ... existing sentiment logic ...

    # FIX: Division by zero check
    if request.take_profit and request.stop_loss:
        tp_distance = abs(request.take_profit - entry_price) / entry_price
        sl_distance = abs(entry_price - request.stop_loss)

        # ADD THIS CHECK
        if sl_distance > 0:
            risk_reward = tp_distance / (sl_distance / entry_price)
        else:
            risk_reward = 0
            reasons.append("Stop loss distance is zero")

        if risk_reward >= 2.0:
            confidence += 0.10
            reasons.append(f"Good risk/reward ratio ({risk_reward:.1f}:1): +10%")
        elif risk_reward < 1.0 and risk_reward > 0:
            confidence -= 0.10
            reasons.append(f"Poor risk/reward ratio ({risk_reward:.1f}:1): -10%")

    # ... rest of existing code ...

# ADD: Health check endpoint with cache stats
@app.get("/health")
async def health() -> dict:
    """Enhanced health check with service status"""
    cache_stats = get_all_cache_stats()

    return {
        "status": "ok",
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "cache": cache_stats,
        "version": "0.1.0"
    }

Phase 2: Mobile App Improvements (2-3 hours)

1. Add Dependencies

File: mobile/package.json

Add to dependencies:

"dependencies": {
  // ... existing deps ...
  "@react-native-community/netinfo": "^11.3.1"
}

Install:

cd mobile
npm install @react-native-community/netinfo

2. Create Validation Utilities

New File: mobile/src/utils/validation.ts

/**
 * Input validation utilities for TradeCalc mobile app
 */

export function parseValidNumber(value: string): number | undefined {
  const trimmed = value.trim();
  if (!trimmed) return undefined;

  const parsed = parseFloat(trimmed);
  return !isNaN(parsed) && parsed > 0 ? parsed : undefined;
}

export function validatePriceInput(value: string): {
  isValid: boolean;
  error?: string;
  parsed?: number;
} {
  if (!value || !value.trim()) {
    return { isValid: true }; // Empty is valid (optional field)
  }

  const parsed = parseValidNumber(value);

  if (parsed === undefined) {
    return {
      isValid: false,
      error: 'Please enter a valid positive number'
    };
  }

  if (parsed < 0.00001) {
    return {
      isValid: false,
      error: 'Price too small (minimum: 0.00001)'
    };
  }

  if (parsed > 1000000) {
    return {
      isValid: false,
      error: 'Price too large (maximum: 1,000,000)'
    };
  }

  return { isValid: true, parsed };
}

export function formatPrice(price: number, symbol: string): string {
  // Crypto needs more decimals
  if (symbol.includes('BTC') || symbol.includes('ETH')) {
    return price.toFixed(2);
  }

  // Forex pairs
  if (symbol.includes('USD') && symbol.length === 6) {
    return price.toFixed(5);
  }

  // Gold, silver
  return price.toFixed(2);
}

export function sanitizePriceInput(text: string): string {
  // Remove all non-numeric characters except decimal point
  const cleaned = text.replace(/[^0-9.]/g, '');

  // Allow only one decimal point
  const parts = cleaned.split('.');
  if (parts.length > 2) {
    return parts[0] + '.' + parts.slice(1).join('');
  }

  return cleaned;
}

3. Create Error Handling Utilities

New File: mobile/src/services/errors.ts

import axios from 'axios';

export type ApiErrorResponse = {
  detail?: string;
  message?: string;
  errors?: Record<string, string[]>;
};

export const ERROR_MESSAGES = {
  NETWORK: 'Unable to connect. Please check your internet connection.',
  INVALID_INPUT: 'Please enter valid values and try again.',
  PARSE_FAILED: 'Could not interpret your input. Try rephrasing.',
  UNEXPECTED: 'Something went wrong. Please try again.',
  NO_PERMISSION: 'Permission denied. Please check app settings.',
  SERVER_ERROR: 'Server error. Please try again later.',
  TIMEOUT: 'Request timed out. Please try again.',
} as const;

export function handleApiError(error: any): string {
  if (axios.isAxiosError(error)) {
    // Network error
    if (!error.response) {
      return ERROR_MESSAGES.NETWORK;
    }

    // Server returned error
    const data = error.response.data as ApiErrorResponse;

    // Try to extract meaningful message
    if (data?.detail) {
      return data.detail;
    }

    if (data?.message) {
      return data.message;
    }

    // Status code based messages
    const status = error.response.status;
    if (status >= 500) {
      return ERROR_MESSAGES.SERVER_ERROR;
    }

    if (status === 400) {
      return ERROR_MESSAGES.INVALID_INPUT;
    }

    if (status === 404) {
      return 'Resource not found';
    }

    if (status === 429) {
      return 'Too many requests. Please wait a moment.';
    }
  }

  // Generic error
  if (error.message) {
    return error.message;
  }

  return ERROR_MESSAGES.UNEXPECTED;
}

4. Fix SignalRatingScreen

File: mobile/src/screens/SignalRatingScreen.tsx

Changes:

// Add imports at top
import { validatePriceInput, sanitizePriceInput, formatPrice } from '@utils/validation';
import { handleApiError } from '@services/errors';
import NetInfo from '@react-native-community/netinfo';

// Add validation state
const [validationErrors, setValidationErrors] = useState<{
  entryPrice?: string;
  stopLoss?: string;
  takeProfit?: string;
}>({});

// Replace input handlers (lines 135-166)
const handlePriceChange = (
  value: string,
  setter: (val: string) => void,
  field: 'entryPrice' | 'stopLoss' | 'takeProfit'
) => {
  const sanitized = sanitizePriceInput(value);
  setter(sanitized);

  // Clear error for this field
  setValidationErrors(prev => ({ ...prev, [field]: undefined }));
};

<TextInput
  style={styles.input}
  placeholder="Use current market price"
  placeholderTextColor="#5A6B8C"
  value={entryPrice}
  onChangeText={(text) => handlePriceChange(text, setEntryPrice, 'entryPrice')}
  keyboardType="decimal-pad"
/>

// Update handleRateSignal (lines 37-58)
const handleRateSignal = useCallback(async () => {
  // Check network first
  const netState = await NetInfo.fetch();
  if (!netState.isConnected) {
    setError('No internet connection. Please check your network.');
    return;
  }

  // Validate inputs
  const errors: typeof validationErrors = {};
  let hasError = false;

  if (entryPrice) {
    const validation = validatePriceInput(entryPrice);
    if (!validation.isValid) {
      errors.entryPrice = validation.error;
      hasError = true;
    }
  }

  if (stopLoss) {
    const validation = validatePriceInput(stopLoss);
    if (!validation.isValid) {
      errors.stopLoss = validation.error;
      hasError = true;
    }
  }

  if (takeProfit) {
    const validation = validatePriceInput(takeProfit);
    if (!validation.isValid) {
      errors.takeProfit = validation.error;
      hasError = true;
    }
  }

  if (hasError) {
    setValidationErrors(errors);
    setError('Please fix the highlighted fields');
    return;
  }

  setLoading(true);
  setError(null);
  setRating(null);
  setValidationErrors({});

  try {
    const request: RateSignalRequest = {
      symbol,
      action,
      entryPrice: entryPrice ? parseFloat(entryPrice) : undefined,
      stopLoss: stopLoss ? parseFloat(stopLoss) : undefined,
      takeProfit: takeProfit ? parseFloat(takeProfit) : undefined
    };

    const result = await rateSignal(request);
    setRating(result);
  } catch (err: any) {
    setError(handleApiError(err));
  } finally {
    setLoading(false);
  }
}, [symbol, action, entryPrice, stopLoss, takeProfit]);

// Show validation errors below inputs
{validationErrors.entryPrice && (
  <Text style={styles.validationError}>{validationErrors.entryPrice}</Text>
)}

// Fix market data rendering with null checks (lines 256-289)
{rating.market_data && (
  <View style={styles.marketDataSection}>
    <Text style={styles.marketDataTitle}>Market Data</Text>
    <View style={styles.marketDataGrid}>
      {rating.market_data.bid !== undefined && rating.market_data.bid !== null && (
        <View style={styles.marketDataItem}>
          <Text style={styles.dataLabel}>Bid:</Text>
          <Text style={styles.dataValue}>
            ${formatPrice(rating.market_data.bid, rating.symbol)}
          </Text>
        </View>
      )}
      {/* Repeat for ask, day_high, day_low, change_pct */}
    </View>
  </View>
)}

// Add validation error style
const styles = StyleSheet.create({
  // ... existing styles ...
  validationError: {
    color: '#F97373',
    fontSize: 12,
    marginTop: 4
  }
});

5. Fix useSpeechToText

File: mobile/src/hooks/useSpeechToText.ts

Changes:

// Replace useEffect (lines 71-75)
useEffect(() => {
  const mounted = { current: true };

  return () => {
    mounted.current = false;
    // Cleanup without dependencies
    if (activeRef.current) {
      SpeechRecognition.stopAsync().catch(() => {
        if (__DEV__) console.warn('Cleanup stop recording failed');
      });
      activeRef.current = false;
    }
  };
}, []); // No dependencies

// Fix error handling to not silently swallow (lines 55, 61, 67)
stopRecording().catch((err) => {
  if (__DEV__) console.warn('Stop recording failed:', err);
});

Phase 3: Deployment Configuration (1-2 hours)

1. Create EAS Configuration

New File: mobile/eas.json

{
  "cli": {
    "version": ">= 5.9.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "env": {
        "API_BASE_URL": "http://localhost:8100",
        "PREMIUM_ENABLED": "true"
      }
    },
    "preview": {
      "distribution": "internal",
      "ios": {
        "simulator": false
      },
      "env": {
        "API_BASE_URL": "https://staging-api.tradecalc.com",
        "PREMIUM_ENABLED": "true"
      }
    },
    "production": {
      "distribution": "store",
      "env": {
        "API_BASE_URL": "https://api.tradecalc.com",
        "PREMIUM_ENABLED": "true"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "YOUR_APPLE_ID@example.com",
        "ascAppId": "YOUR_APP_STORE_CONNECT_ID"
      },
      "android": {
        "serviceAccountKeyPath": "./google-play-service-account.json"
      }
    }
  }
}

2. Create Dynamic App Config

New File: mobile/app.config.ts

import { ExpoConfig, ConfigContext } from '@expo/config';

export default ({ config }: ConfigContext): ExpoConfig => {
  const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:8100';
  const premiumEnabled = process.env.PREMIUM_ENABLED === 'true';

  return {
    ...config,
    name: 'TradeCalc',
    slug: 'tradecalc',
    version: '0.1.0',
    orientation: 'portrait',
    icon: './assets/icon.png',
    userInterfaceStyle: 'automatic',
    splash: {
      image: './assets/splash.png',
      resizeMode: 'contain',
      backgroundColor: '#0B1220'
    },
    assetBundlePatterns: ['**/*'],
    ios: {
      supportsTablet: true,
      bundleIdentifier: 'com.tradecalc.mobile',
      buildNumber: '1',
      infoPlist: {
        NSMicrophoneUsageDescription: 'TradeCalc uses your microphone for voice input to interpret trading queries.',
        NSSpeechRecognitionUsageDescription: 'TradeCalc uses speech recognition to convert your voice into trading calculations.',
        NSCameraUsageDescription: 'TradeCalc may use your camera to scan trading documents (future feature).'
      },
      config: {
        usesNonExemptEncryption: false
      }
    },
    android: {
      adaptiveIcon: {
        foregroundImage: './assets/adaptive-icon.png',
        backgroundColor: '#0B1220'
      },
      package: 'com.tradecalc.mobile',
      versionCode: 1,
      permissions: [
        'RECORD_AUDIO',
        'INTERNET'
      ]
    },
    web: {
      bundler: 'metro',
      output: 'static',
      favicon: './assets/favicon.png'
    },
    plugins: [
      'expo-router'
    ],
    experiments: {
      typedRoutes: true
    },
    extra: {
      apiBaseUrl,
      premiumEnabled,
      eas: {
        projectId: 'YOUR_EAS_PROJECT_ID'
      }
    }
  };
};

3. Update PM2 Configuration

Already done in previous changes! The file ecosystem.mobile_calc.config.js has been updated with proper environment loading and logging.


Testing & Deployment

Backend Testing

cd /home/claude-dev/repos/tradecalc
source .venv/bin/activate

# Run tests
pytest -v

# Check for any failures
pytest --cov=tradecalc --cov-report=term

# Lint check
ruff check src tests

Mobile Testing

cd mobile

# Install dependencies
npm install

# Run tests
npm test

# Lint
npm run lint

# Type check
npm run typecheck

Deploy Backend

cd /home/claude-dev/repos/tradecalc
source .venv/bin/activate

# Pull latest
git pull

# Install deps
pip install -e .

# Restart PM2
pm2 restart mobile_calc-api
pm2 logs mobile_calc-api --lines 20

# Verify
curl http://localhost:8100/health

Build iOS

cd mobile

# Login to EAS
eas login

# Configure project (first time only)
eas build:configure

# Build for TestFlight
eas build --platform ios --profile production

# Wait 15-30 minutes...

# Submit to TestFlight
eas submit --platform ios --latest

Build Android

cd mobile

# Build for Play Store
eas build --platform android --profile production

# Wait 15-30 minutes...

# Submit to Play Store
eas submit --platform android --latest

Verification Checklist

Backend

  • PM2 shows process running
  • /health endpoint returns 200 with cache stats
  • /interpret responds in < 500ms
  • /rate-signal (XAUUSD) responds in < 1s
  • No errors in PM2 logs
  • Cache hit rate > 0% after a few requests

Mobile

  • App launches without crashes
  • Voice input works (on device)
  • Signal rating shows results
  • Validation prevents invalid input
  • Offline message shown when no network
  • No TypeScript errors
  • No console warnings

Deployment

  • iOS TestFlight build available
  • Android internal testing track live
  • App Store metadata complete
  • Play Store metadata complete
  • Privacy policy URL active
  • Support URL active

Troubleshooting

"No module named cachetools":

source .venv/bin/activate
pip install cachetools

"PM2 process keeps restarting":

pm2 logs mobile_calc-api --err
# Check for import errors or missing dependencies

"EAS build fails":

# Check eas.json syntax
eas build:configure
# Verify app.config.ts exports correctly

"Type errors in mobile":

cd mobile
rm -rf node_modules
npm install
npm run typecheck

Next Steps After Implementation

  1. Monitor in production for 24-48 hours
  2. Gather TestFlight feedback from beta testers
  3. Submit to App Store (review takes 1-3 days)
  4. Submit to Play Store (review takes 1-7 days)
  5. Set up monitoring (UptimeRobot, Sentry, etc.)
  6. Create backup strategy (daily automated backups)
  7. Plan v0.2.0 features based on user feedback

Estimated Total Time: 8-12 hours focused work Expected Result: Production-ready app with 5-7x performance improvement

Good luck! 🚀