722 lines
35 KiB
Python
722 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""
|
|
ui_routes.py - API Endpoints and Route Handlers
|
|
|
|
Defines all FastAPI routes and business logic for API endpoints
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, Any, Optional, List
|
|
|
|
from fastapi import HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
from dotenv import set_key, dotenv_values
|
|
|
|
# UI and models
|
|
from ui_models import (
|
|
TradingPairConfig,
|
|
TradingPairAdd,
|
|
BulkDownloadRequest,
|
|
GapFillRequest,
|
|
ConfigUpdate,
|
|
EnvVarUpdate,
|
|
ChartDataRequest,
|
|
AutoGapFillRequest,
|
|
GapDetectionRequest,
|
|
serialize_for_json,
|
|
)
|
|
|
|
from ui_template_dashboard import get_dashboard_html
|
|
from ui_template_config import get_config_html
|
|
from ui_state import get_current_status
|
|
from utils import load_config, save_config, validate_symbol, reload_env_vars
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _tz_aware(dt: datetime) -> datetime:
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def _ok(data: Any, status: str = "success", http_status: int = 200) -> JSONResponse:
|
|
return JSONResponse(content={"status": status, "data": serialize_for_json(data)}, status_code=http_status)
|
|
|
|
|
|
def _err(message: str, http_status: int = 500, extra: Optional[Dict[str, Any]] = None) -> JSONResponse:
|
|
payload = {"status": "error", "message": message}
|
|
if extra:
|
|
payload.update(extra)
|
|
return JSONResponse(content=payload, status_code=http_status)
|
|
|
|
|
|
class APIRoutes:
|
|
"""Encapsulates all API route handlers"""
|
|
|
|
def __init__(self, app, db_manager, data_collector, config, state_manager):
|
|
self.app = app
|
|
self.db_manager = db_manager
|
|
self.data_collector = data_collector
|
|
self.config = config
|
|
self.state_manager = state_manager
|
|
|
|
# Register all routes
|
|
self._register_routes()
|
|
|
|
def _register_routes(self):
|
|
"""Register all API routes"""
|
|
|
|
# ---------------------------
|
|
# Pages
|
|
# ---------------------------
|
|
|
|
@self.app.get("/", response_class=HTMLResponse)
|
|
async def dashboard():
|
|
"""Serve the main dashboard"""
|
|
return get_dashboard_html()
|
|
|
|
@self.app.get("/config", response_class=HTMLResponse)
|
|
async def config_page():
|
|
"""Serve the configuration management page"""
|
|
return get_config_html()
|
|
|
|
@self.app.get("/gaps", response_class=HTMLResponse)
|
|
async def gaps_page():
|
|
"""Serve the gap monitoring page"""
|
|
from ui_template_gaps import get_gaps_monitoring_html
|
|
return get_gaps_monitoring_html()
|
|
|
|
# ---------------------------
|
|
# Status
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/stats")
|
|
async def get_stats():
|
|
"""Get current system statistics"""
|
|
try:
|
|
status = await get_current_status(self.db_manager, self.data_collector, self.config)
|
|
return JSONResponse(content=serialize_for_json(status))
|
|
except Exception as e:
|
|
logger.error(f"Error getting stats: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# ---------------------------
|
|
# Gaps and Coverage
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/gaps/all-pairs")
|
|
async def get_all_pairs_gaps():
|
|
"""Get gap status for all trading pairs"""
|
|
try:
|
|
if not self.db_manager:
|
|
logger.error("Database manager not initialized")
|
|
return _err("Database not initialized", 500)
|
|
logger.info("Fetching gap status for all pairs")
|
|
status = await self.db_manager.get_all_pairs_gap_status()
|
|
logger.info(f"Retrieved gap status for {len(status)} pair-interval combinations")
|
|
return _ok(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting all pairs gaps: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/details/{symbol}/{interval}")
|
|
async def get_gap_details(symbol: str, interval: str):
|
|
"""Get detailed gap information including daily coverage"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
|
|
sym = symbol.upper()
|
|
gap_info = await self.db_manager.detect_gaps(sym, interval)
|
|
end_date = datetime.utcnow()
|
|
start_date = end_date - timedelta(days=90)
|
|
daily_coverage = await self.db_manager.get_data_coverage_by_day(sym, interval, start_date, end_date)
|
|
|
|
data = {
|
|
"coverage_percent": gap_info.get('coverage', {}).get('coverage_percent', 0),
|
|
"total_records": gap_info.get('coverage', {}).get('total_records', 0),
|
|
"missing_records": gap_info.get('coverage', {}).get('missing_records', 0),
|
|
"gaps": gap_info.get('gaps', []),
|
|
"daily_coverage": daily_coverage.get('daily_coverage', []),
|
|
}
|
|
return _ok(data)
|
|
except Exception as e:
|
|
logger.error(f"Error getting gap details: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/gaps/fill-intelligent")
|
|
async def fill_gaps_intelligent(request: Request):
|
|
"""Intelligently fill gaps with multiple attempts and averaging fallback"""
|
|
try:
|
|
body = await request.json()
|
|
symbol = body.get('symbol')
|
|
interval = body.get('interval')
|
|
max_attempts = int(body.get('max_attempts', 3))
|
|
|
|
if not symbol or not interval:
|
|
return _err("Missing symbol or interval", 400)
|
|
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
|
|
result = await self.db_manager.fill_gaps_intelligently(symbol.upper(), interval, max_attempts)
|
|
logger.info(f"Intelligent gap fill completed: {result}")
|
|
return _ok(result)
|
|
except Exception as e:
|
|
logger.error(f"Error in intelligent gap fill: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/prioritized/{symbol}/{interval}")
|
|
async def get_prioritized_gaps(symbol: str, interval: str):
|
|
"""Get gaps sorted by priority (recent and small gaps first)"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
prioritized = await self.db_manager.get_prioritized_gaps(symbol.upper(), interval)
|
|
return _ok(prioritized)
|
|
except Exception as e:
|
|
logger.error(f"Error getting prioritized gaps: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/progress/{symbol}/{interval}")
|
|
async def get_gap_progress(symbol: str, interval: str):
|
|
"""Get real-time progress and estimated completion time"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
progress = await self.db_manager.get_gap_fill_progress(symbol.upper(), interval)
|
|
return _ok(progress)
|
|
except Exception as e:
|
|
logger.error(f"Error getting gap progress: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/health/{symbol}/{interval}")
|
|
async def get_data_health(symbol: str, interval: str):
|
|
"""Get comprehensive data health analysis"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
health = await self.db_manager.check_data_health(symbol.upper(), interval)
|
|
return _ok(health)
|
|
except Exception as e:
|
|
logger.error(f"Error checking data health: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/gaps/smart-fill/{symbol}")
|
|
async def smart_fill_gaps(symbol: str):
|
|
"""Intelligently fill gaps starting with highest priority"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
|
|
from utils import load_config
|
|
cfg = load_config()
|
|
intervals = cfg.get('collection', {}).get('candle_intervals', ['1m', '5m', '15m', '1h', '4h', '1d'])
|
|
|
|
results: List[Dict[str, Any]] = []
|
|
for interval in intervals:
|
|
prioritized = await self.db_manager.get_prioritized_gaps(symbol.upper(), interval)
|
|
if not prioritized:
|
|
continue
|
|
filled = 0
|
|
for gap in prioritized[:5]:
|
|
if gap.get('missing_candles', 0) <= 100:
|
|
try:
|
|
await self.db_manager.fill_gaps_intelligently(symbol.upper(), interval, max_attempts=3)
|
|
filled += 1
|
|
except Exception as e:
|
|
logger.error(f"Error filling gap: {e}")
|
|
results.append({'interval': interval, 'gaps_filled': filled, 'total_gaps': len(prioritized)})
|
|
|
|
return JSONResponse(content={"status": "success", "message": f"Smart fill completed for {symbol}", "data": results})
|
|
except Exception as e:
|
|
logger.error(f"Error in smart fill: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/gaps/fill")
|
|
async def fill_gaps(request: GapFillRequest):
|
|
"""Fill data gaps"""
|
|
try:
|
|
if not self.data_collector:
|
|
raise HTTPException(status_code=500, detail="Data collector not initialized")
|
|
|
|
gap_start = datetime.fromisoformat(request.gap_start)
|
|
gap_end = datetime.fromisoformat(request.gap_end)
|
|
gap_start = _tz_aware(gap_start)
|
|
gap_end = _tz_aware(gap_end)
|
|
|
|
await self.data_collector.bulk_download_historical_data(
|
|
request.symbol.upper(),
|
|
gap_start,
|
|
gap_end,
|
|
[request.interval],
|
|
)
|
|
logger.info(f"Gap filled for {request.symbol} {request.interval}")
|
|
return JSONResponse(content={"status": "success", "message": "Gap filled successfully"})
|
|
except Exception as e:
|
|
logger.error(f"Error filling gap: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/gaps/auto-fill")
|
|
async def auto_fill_gaps(request: AutoGapFillRequest):
|
|
"""Automatically fill gaps for a symbol"""
|
|
try:
|
|
if not self.data_collector:
|
|
raise HTTPException(status_code=500, detail="Data collector not initialized")
|
|
result = await self.data_collector.auto_fill_gaps(
|
|
request.symbol.upper(),
|
|
request.intervals,
|
|
request.fill_genuine_gaps,
|
|
)
|
|
logger.info(f"Auto gap fill completed for {request.symbol}: {result}")
|
|
return JSONResponse(content={"status": "success", "message": f"Filled gaps for {request.symbol}", "result": serialize_for_json(result)})
|
|
except Exception as e:
|
|
logger.error(f"Error in auto gap fill: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/summary")
|
|
async def get_gaps_summary():
|
|
"""Get summary of all gaps across all symbols"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
summary = await self.db_manager.get_all_gaps_summary()
|
|
return _ok(summary)
|
|
except Exception as e:
|
|
logger.error(f"Error getting gaps summary: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/status/{symbol}/{interval}")
|
|
async def get_gap_status(symbol: str, interval: str):
|
|
"""Get gap fill status for a specific symbol/interval"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
status = await self.db_manager.get_gap_fill_status(symbol.upper(), interval)
|
|
return _ok(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting gap status: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.get("/api/gaps/{symbol}/{interval}")
|
|
async def detect_gaps(symbol: str, interval: str):
|
|
"""Detect data gaps"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
gaps = await self.db_manager.detect_gaps(symbol.upper(), interval)
|
|
return JSONResponse(content={"status": "success", "gaps": serialize_for_json(gaps)})
|
|
except Exception as e:
|
|
logger.error(f"Error detecting gaps: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/gaps/fill-genuine/{symbol}/{interval}")
|
|
async def fill_genuine_gaps(symbol: str, interval: str):
|
|
"""Fill genuine empty gaps with intelligent averaging"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
gap_config = self.config.get('gap_filling', {})
|
|
max_consecutive = int(gap_config.get('max_consecutive_empty_candles', 5))
|
|
lookback = int(gap_config.get('averaging_lookback_candles', 10))
|
|
filled_count = await self.db_manager.fill_genuine_gaps_with_averages(
|
|
symbol.upper(), interval, max_consecutive, lookback
|
|
)
|
|
logger.info(f"Filled {filled_count} genuine gaps for {symbol} {interval}")
|
|
return JSONResponse(
|
|
content={
|
|
"status": "success",
|
|
"message": f"Filled {filled_count} genuine empty candles",
|
|
"filled_count": filled_count,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error filling genuine gaps: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
# ---------------------------
|
|
# Symbols and Prices
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/symbols")
|
|
async def get_symbols():
|
|
"""Get list of all available symbols"""
|
|
try:
|
|
if not self.db_manager:
|
|
logger.error("Database manager not initialized")
|
|
return JSONResponse(content={"status": "error", "symbols": []}, status_code=500)
|
|
symbols = await self.db_manager.get_available_symbols()
|
|
logger.info(f"Retrieved {len(symbols)} symbols from database")
|
|
return JSONResponse(content={"status": "success", "symbols": symbols})
|
|
except Exception as e:
|
|
logger.error(f"Error getting symbols: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "symbols": []}, status_code=500)
|
|
|
|
@self.app.get("/api/price-trends/{symbol}")
|
|
async def get_price_trends(symbol: str):
|
|
"""Get current price and trend indicators for multiple timeframes"""
|
|
try:
|
|
if not self.db_manager:
|
|
logger.error("Database manager not initialized")
|
|
return _err("Database not initialized", 500)
|
|
logger.info(f"Price trends request for {symbol}")
|
|
data = await self.db_manager.get_current_price_and_trends_with_volume(symbol.upper())
|
|
if not data:
|
|
logger.warning(f"No price data found for {symbol}")
|
|
return _err(f"No data found for {symbol}. Please start data collection first.", 404)
|
|
pair_config = next((p for p in self.config.get('trading_pairs', []) if p['symbol'] == symbol.upper()), None)
|
|
data['enabled'] = pair_config.get('enabled', False) if pair_config else False
|
|
logger.info(f"Returning price trends for {symbol}: price={data.get('current_price')}")
|
|
return _ok(data)
|
|
except Exception as e:
|
|
logger.error(f"Error getting price trends: {e}", exc_info=True)
|
|
return _err(f"Error retrieving price trends: {str(e)}", 500)
|
|
|
|
# ---------------------------
|
|
# Collection control
|
|
# ---------------------------
|
|
|
|
@self.app.post("/api/collection/start")
|
|
async def start_collection():
|
|
"""Start data collection"""
|
|
try:
|
|
if not self.data_collector:
|
|
raise HTTPException(status_code=500, detail="Data collector not initialized")
|
|
if self.state_manager.get("is_collecting", False):
|
|
return JSONResponse(content={"status": "info", "message": "Collection already running"})
|
|
await self.data_collector.start_continuous_collection()
|
|
self.state_manager.update(is_collecting=True)
|
|
logger.info("Collection started via API")
|
|
return JSONResponse(content={"status": "success", "message": "Collection started"})
|
|
except Exception as e:
|
|
logger.error(f"Error starting collection: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.post("/api/collection/stop")
|
|
async def stop_collection():
|
|
"""Stop data collection"""
|
|
try:
|
|
if not self.data_collector:
|
|
raise HTTPException(status_code=500, detail="Data collector not initialized")
|
|
if not self.state_manager.get("is_collecting", False):
|
|
return JSONResponse(content={"status": "info", "message": "Collection not running"})
|
|
await self.data_collector.stop_continuous_collection()
|
|
self.state_manager.update(is_collecting=False)
|
|
logger.info("Collection stopped via API")
|
|
return JSONResponse(content={"status": "success", "message": "Collection stopped"})
|
|
except Exception as e:
|
|
logger.error(f"Error stopping collection: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
# ---------------------------
|
|
# Configuration
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/config")
|
|
async def get_configuration():
|
|
"""Get current configuration"""
|
|
try:
|
|
cfg = load_config()
|
|
return JSONResponse(content=serialize_for_json(cfg))
|
|
except Exception as e:
|
|
logger.error(f"Error getting config: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@self.app.post("/api/config")
|
|
async def update_configuration(request: Request):
|
|
"""Update configuration - accepts raw JSON body"""
|
|
try:
|
|
body = await request.json()
|
|
logger.info(f"Received config update keys: {list(body.keys())}")
|
|
|
|
current_config = load_config()
|
|
# Deep merge/replace top-level keys
|
|
for key, value in body.items():
|
|
if key in current_config and isinstance(current_config[key], dict) and isinstance(value, dict):
|
|
current_config[key].update(value)
|
|
else:
|
|
current_config[key] = value
|
|
|
|
save_config(current_config)
|
|
self.config.clear()
|
|
self.config.update(current_config)
|
|
logger.info("Configuration updated successfully")
|
|
return JSONResponse(content={"status": "success", "message": "Configuration updated"})
|
|
except Exception as e:
|
|
logger.error(f"Error updating config: {e}", exc_info=True)
|
|
return _err(str(e), 500)
|
|
|
|
@self.app.post("/api/trading-pairs")
|
|
async def add_trading_pair(pair: TradingPairAdd):
|
|
"""Add a new trading pair"""
|
|
try:
|
|
if not validate_symbol(pair.symbol.upper()):
|
|
return JSONResponse(content={"status": "error", "message": "Invalid symbol format"}, status_code=400)
|
|
|
|
cfg = load_config()
|
|
existing = [p for p in cfg.get('trading_pairs', []) if p['symbol'] == pair.symbol.upper()]
|
|
if existing:
|
|
return JSONResponse(content={"status": "error", "message": "Trading pair already exists"}, status_code=409)
|
|
|
|
record_from_date = pair.record_from_date or cfg.get('collection', {}).get('default_record_from_date', '2020-01-01T00:00:00Z')
|
|
cfg.setdefault('trading_pairs', []).append({
|
|
'symbol': pair.symbol.upper(),
|
|
'enabled': True,
|
|
'priority': pair.priority,
|
|
'record_from_date': record_from_date,
|
|
})
|
|
save_config(cfg)
|
|
self.config.clear()
|
|
self.config.update(cfg)
|
|
logger.info(f"Added trading pair: {pair.symbol}")
|
|
return JSONResponse(content={"status": "success", "message": f"Added {pair.symbol}"})
|
|
except Exception as e:
|
|
logger.error(f"Error adding trading pair: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.put("/api/trading-pairs/{symbol}")
|
|
async def update_trading_pair(symbol: str, request: Request):
|
|
"""Update a trading pair's configuration"""
|
|
try:
|
|
update = await request.json()
|
|
logger.info(f"Updating trading pair {symbol}: {update}")
|
|
cfg = load_config()
|
|
|
|
pair_found = False
|
|
for pair in cfg.get('trading_pairs', []):
|
|
if pair['symbol'] == symbol.upper():
|
|
if 'enabled' in update:
|
|
pair['enabled'] = bool(update['enabled'])
|
|
if 'priority' in update:
|
|
pair['priority'] = int(update['priority'])
|
|
if 'record_from_date' in update:
|
|
pair['record_from_date'] = update['record_from_date']
|
|
pair_found = True
|
|
break
|
|
|
|
if not pair_found:
|
|
return JSONResponse(content={"status": "error", "message": "Trading pair not found"}, status_code=404)
|
|
|
|
save_config(cfg)
|
|
self.config.clear()
|
|
self.config.update(cfg)
|
|
logger.info(f"Updated trading pair: {symbol}")
|
|
return JSONResponse(content={"status": "success", "message": f"Updated {symbol}"})
|
|
except Exception as e:
|
|
logger.error(f"Error updating trading pair: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.delete("/api/trading-pairs/{symbol}")
|
|
async def remove_trading_pair(symbol: str):
|
|
"""Remove a trading pair"""
|
|
try:
|
|
cfg = load_config()
|
|
original_count = len(cfg.get('trading_pairs', []))
|
|
cfg['trading_pairs'] = [p for p in cfg.get('trading_pairs', []) if p['symbol'] != symbol.upper()]
|
|
|
|
if len(cfg['trading_pairs']) == original_count:
|
|
return JSONResponse(content={"status": "error", "message": "Trading pair not found"}, status_code=404)
|
|
|
|
save_config(cfg)
|
|
self.config.clear()
|
|
self.config.update(cfg)
|
|
logger.info(f"Removed trading pair: {symbol}")
|
|
return JSONResponse(content={"status": "success", "message": f"Removed {symbol}"})
|
|
except Exception as e:
|
|
logger.error(f"Error removing trading pair: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.post("/api/indicators/toggle/{indicator_name}")
|
|
async def toggle_indicator(indicator_name: str):
|
|
"""Toggle a technical indicator on/off"""
|
|
try:
|
|
cfg = load_config()
|
|
enabled_indicators = cfg.setdefault('technical_indicators', {}).setdefault('enabled', [])
|
|
if indicator_name in enabled_indicators:
|
|
enabled_indicators.remove(indicator_name)
|
|
action = "disabled"
|
|
else:
|
|
enabled_indicators.append(indicator_name)
|
|
action = "enabled"
|
|
save_config(cfg)
|
|
self.config.clear()
|
|
self.config.update(cfg)
|
|
logger.info(f"Indicator {indicator_name} {action}")
|
|
return JSONResponse(content={"status": "success", "message": f"Indicator {indicator_name} {action}", "enabled": indicator_name in enabled_indicators})
|
|
except Exception as e:
|
|
logger.error(f"Error toggling indicator: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.put("/api/indicators/{indicator_name}/periods")
|
|
async def update_indicator_periods(indicator_name: str, request: Request):
|
|
"""Update periods for a technical indicator"""
|
|
try:
|
|
body = await request.json()
|
|
periods = body.get('periods')
|
|
if periods is None:
|
|
return JSONResponse(content={"status": "error", "message": "Missing 'periods' in request"}, status_code=400)
|
|
|
|
cfg = load_config()
|
|
periods_cfg = cfg.setdefault('technical_indicators', {}).setdefault('periods', {})
|
|
if indicator_name not in periods_cfg:
|
|
return JSONResponse(content={"status": "error", "message": f"Unknown indicator: {indicator_name}"}, status_code=404)
|
|
|
|
periods_cfg[indicator_name] = periods
|
|
save_config(cfg)
|
|
self.config.clear()
|
|
self.config.update(cfg)
|
|
logger.info(f"Updated {indicator_name} periods to {periods}")
|
|
return JSONResponse(content={"status": "success", "message": f"Updated {indicator_name} periods"})
|
|
except Exception as e:
|
|
logger.error(f"Error updating indicator periods: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
# ---------------------------
|
|
# Chart and Data
|
|
# ---------------------------
|
|
|
|
@self.app.post("/api/chart-data")
|
|
async def get_chart_data(request: ChartDataRequest):
|
|
"""Get chart data for visualization"""
|
|
try:
|
|
if not self.db_manager:
|
|
logger.error("Database manager not initialized")
|
|
return JSONResponse(content={"status": "error", "message": "Database not initialized"}, status_code=500)
|
|
|
|
logger.info(f"Chart data request: symbol={request.symbol}, interval={request.interval}, limit={request.limit}")
|
|
data = await self.db_manager.get_recent_candles(request.symbol.upper(), request.interval, request.limit)
|
|
logger.info(f"Retrieved {len(data) if data else 0} candles from database")
|
|
if not data:
|
|
logger.warning(f"No data found for {request.symbol} at {request.interval}")
|
|
return JSONResponse(content={"status": "error", "message": f"No data found for {request.symbol} at {request.interval}. Please start data collection or download historical data first."}, status_code=404)
|
|
logger.info(f"Returning {len(data)} candles for {request.symbol}")
|
|
return JSONResponse(content={"status": "success", "data": data})
|
|
except Exception as e:
|
|
logger.error(f"Error getting chart data: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": f"Error retrieving chart data: {str(e)}"}, status_code=500)
|
|
|
|
@self.app.post("/api/bulk-download")
|
|
async def bulk_download(request: BulkDownloadRequest):
|
|
"""Download historical data in bulk"""
|
|
try:
|
|
if not self.data_collector:
|
|
raise HTTPException(status_code=500, detail="Data collector not initialized")
|
|
|
|
start_date = datetime.fromisoformat(request.start_date)
|
|
end_date = datetime.fromisoformat(request.end_date) if request.end_date else datetime.utcnow()
|
|
start_date = _tz_aware(start_date)
|
|
end_date = _tz_aware(end_date)
|
|
|
|
intervals = request.intervals or ['1h', '4h', '1d']
|
|
results = []
|
|
|
|
for symbol in request.symbols:
|
|
try:
|
|
symu = symbol.upper()
|
|
# Initialize progress for UI
|
|
self.data_collector.download_progress[symu] = {
|
|
'status': 'pending',
|
|
'intervals': {i: {'status': 'pending', 'records': 0} for i in intervals},
|
|
'start_time': datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
# Spawn task
|
|
task = asyncio.create_task(
|
|
self.data_collector.bulk_download_historical_data(symu, start_date, end_date, intervals)
|
|
)
|
|
results.append({'symbol': symu, 'status': 'started', 'intervals': intervals})
|
|
logger.info(f"Bulk download started for {symbol}")
|
|
except Exception as ie:
|
|
logger.error(f"Error starting bulk download for {symbol}: {ie}")
|
|
results.append({'symbol': symu, 'status': 'error', 'error': str(ie)})
|
|
|
|
return JSONResponse(content={"status": "success", "message": f"Bulk download started for {len(request.symbols)} symbol(s)", "results": results})
|
|
except Exception as e:
|
|
logger.error(f"Error starting bulk download: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.get("/api/download-progress")
|
|
async def get_download_progress():
|
|
"""Get progress for all active downloads"""
|
|
try:
|
|
if not self.data_collector:
|
|
return JSONResponse(content={"status": "error", "message": "Data collector not initialized"}, status_code=500)
|
|
progress = await self.data_collector.get_download_progress()
|
|
return JSONResponse(content={"status": "success", "downloads": serialize_for_json(progress)})
|
|
except Exception as e:
|
|
logger.error(f"Error getting download progress: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
# ---------------------------
|
|
# Environment variables
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/env")
|
|
async def get_env_vars():
|
|
"""Get environment variables"""
|
|
try:
|
|
env_vars = dotenv_values('variables.env') or {}
|
|
safe_vars = {
|
|
k: ('***' if any(s in k.upper() for s in ['SECRET', 'KEY', 'PASSWORD', 'TOKEN']) else v)
|
|
for k, v in env_vars.items()
|
|
}
|
|
return JSONResponse(content=safe_vars)
|
|
except Exception as e:
|
|
logger.error(f"Error getting env vars: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@self.app.post("/api/env")
|
|
async def update_env_var(env_update: EnvVarUpdate):
|
|
"""Update environment variable"""
|
|
try:
|
|
key_upper = env_update.key.upper()
|
|
display_value = env_update.value if not any(s in key_upper for s in ['PASSWORD', 'SECRET', 'KEY', 'TOKEN']) else '***'
|
|
logger.info(f"Updating env var: {env_update.key} = {display_value}")
|
|
set_key('variables.env', env_update.key, env_update.value)
|
|
reload_env_vars('variables.env')
|
|
logger.info(f"Updated and reloaded env var: {env_update.key}")
|
|
return JSONResponse(content={"status": "success", "message": f"Updated {env_update.key}"})
|
|
except Exception as e:
|
|
logger.error(f"Error updating env var: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
@self.app.delete("/api/env/{key}")
|
|
async def delete_env_var(key: str):
|
|
"""Delete environment variable"""
|
|
try:
|
|
# Manual edit due to lack of delete in python-dotenv API
|
|
try:
|
|
with open('variables.env', 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
except FileNotFoundError:
|
|
lines = []
|
|
new_lines = [line for line in lines if not line.startswith(f"{key}=")]
|
|
with open('variables.env', 'w', encoding='utf-8') as f:
|
|
f.writelines(new_lines)
|
|
reload_env_vars('variables.env')
|
|
logger.info(f"Deleted env var: {key}")
|
|
return JSONResponse(content={"status": "success", "message": f"Deleted {key}"})
|
|
except Exception as e:
|
|
logger.error(f"Error deleting env var: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
|
|
|
# ---------------------------
|
|
# Database stats
|
|
# ---------------------------
|
|
|
|
@self.app.get("/api/database/stats")
|
|
async def get_database_stats():
|
|
"""Get detailed database statistics"""
|
|
try:
|
|
if not self.db_manager:
|
|
raise HTTPException(status_code=500, detail="Database not initialized")
|
|
stats = await self.db_manager.get_detailed_statistics()
|
|
return JSONResponse(content={"status": "success", "stats": serialize_for_json(stats)})
|
|
except Exception as e:
|
|
logger.error(f"Error getting database stats: {e}", exc_info=True)
|
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|