first commit
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Django
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
db.sqlite3
|
||||||
|
media/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
11
Pipfile
Normal file
11
Pipfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.13"
|
||||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
3
api/admin.py
Normal file
3
api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
api/apps.py
Normal file
5
api/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
name = 'api'
|
||||||
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
3
api/models.py
Normal file
3
api/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
69
api/serializers.py
Normal file
69
api/serializers.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class BitcoinPriceSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = BitcoinPrice
|
||||||
|
fields = ['id', 'timestamp', 'price_usd', 'volume', 'market_cap']
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Convert Decimal to float for JSON
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
data['price_usd'] = float(data['price_usd'])
|
||||||
|
if data['volume']:
|
||||||
|
data['volume'] = float(data['volume'])
|
||||||
|
if data['market_cap']:
|
||||||
|
data['market_cap'] = float(data['market_cap'])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class MarketAnalysisSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = MarketAnalysis
|
||||||
|
fields = ['id', 'timestamp', 'period', 'current_price', 'average_price',
|
||||||
|
'min_price', 'max_price', 'status', 'threshold_percent',
|
||||||
|
'lower_threshold', 'upper_threshold', 'is_event', 'event_type']
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Convert Decimal to float for JSON
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
decimal_fields = ['current_price', 'average_price', 'min_price', 'max_price',
|
||||||
|
'threshold_percent', 'lower_threshold', 'upper_threshold']
|
||||||
|
for field in decimal_fields:
|
||||||
|
if field in data and data[field]:
|
||||||
|
data[field] = float(data[field])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class StatusResponseSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for status API endpoint."""
|
||||||
|
current_price = serializers.FloatField()
|
||||||
|
current_status = serializers.CharField()
|
||||||
|
yearly_average = serializers.FloatField()
|
||||||
|
yearly_min = serializers.FloatField()
|
||||||
|
yearly_max = serializers.FloatField()
|
||||||
|
threshold_percent = serializers.FloatField()
|
||||||
|
lower_threshold = serializers.FloatField()
|
||||||
|
upper_threshold = serializers.FloatField()
|
||||||
|
last_yearly_update = serializers.DateTimeField(allow_null=True)
|
||||||
|
last_hourly_update = serializers.DateTimeField(allow_null=True)
|
||||||
|
stale_yearly = serializers.BooleanField()
|
||||||
|
stale_hourly = serializers.BooleanField()
|
||||||
|
fetch_error = serializers.CharField(allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartDataSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for chart data API endpoint."""
|
||||||
|
timestamps = serializers.ListField(child=serializers.DateTimeField())
|
||||||
|
prices = serializers.ListField(child=serializers.FloatField())
|
||||||
|
events = MarketAnalysisSerializer(many=True)
|
||||||
|
threshold_percent = serializers.FloatField()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for health check endpoint."""
|
||||||
|
status = serializers.CharField()
|
||||||
|
timestamp = serializers.DateTimeField()
|
||||||
|
checks = serializers.DictField()
|
||||||
3
api/tests.py
Normal file
3
api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
23
api/urls.py
Normal file
23
api/urls.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Create a router for viewset endpoints
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'prices', views.BitcoinPriceViewSet, basename='price')
|
||||||
|
router.register(r'events', views.EventsViewSet, basename='event')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Router URLs
|
||||||
|
path('', include(router.urls)),
|
||||||
|
|
||||||
|
# Custom view URLs (matching your Zig API structure)
|
||||||
|
path('status/', views.StatusView.as_view(), name='api-status'),
|
||||||
|
path('chart-data/', views.ChartDataView.as_view(), name='api-chart-data'),
|
||||||
|
path('stats/', views.StatsView.as_view(), name='api-stats'),
|
||||||
|
path('health/', views.HealthCheckView.as_view(), name='api-health'),
|
||||||
|
|
||||||
|
# Convenience endpoints
|
||||||
|
path('latest/', views.StatusView.as_view(), name='api-latest'),
|
||||||
|
path('recent-events/', views.EventsViewSet.as_view({'get': 'recent'}), name='api-recent-events'),
|
||||||
|
]
|
||||||
260
api/views.py
Normal file
260
api/views.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Avg, Min, Max
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis
|
||||||
|
from monitor.services.analyzer import MarketAnalyzer
|
||||||
|
from api.serializers import (
|
||||||
|
BitcoinPriceSerializer,
|
||||||
|
MarketAnalysisSerializer,
|
||||||
|
StatusResponseSerializer,
|
||||||
|
ChartDataSerializer,
|
||||||
|
HealthCheckSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusView(APIView):
|
||||||
|
"""
|
||||||
|
API endpoint for current system status.
|
||||||
|
Returns: JSON similar to your Zig version /api/status
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
|
||||||
|
# Get hourly analysis (as current status)
|
||||||
|
hourly_analysis = analyzer.get_latest_analysis('hourly')
|
||||||
|
|
||||||
|
# Get yearly analysis for yearly stats
|
||||||
|
yearly_analysis = analyzer.get_latest_analysis('yearly')
|
||||||
|
|
||||||
|
# If no analysis exists, run it
|
||||||
|
if not hourly_analysis:
|
||||||
|
hourly_analysis = analyzer.analyze_market('hourly')
|
||||||
|
if not yearly_analysis:
|
||||||
|
yearly_analysis = analyzer.analyze_market('yearly')
|
||||||
|
|
||||||
|
# Prepare status data matching your Zig structure
|
||||||
|
status_data = {
|
||||||
|
'current_price': float(hourly_analysis.current_price) if hourly_analysis else 0.0,
|
||||||
|
'current_status': hourly_analysis.status if hourly_analysis else 'neutral',
|
||||||
|
'yearly_average': float(yearly_analysis.average_price) if yearly_analysis else 0.0,
|
||||||
|
'yearly_min': float(yearly_analysis.min_price) if yearly_analysis else 0.0,
|
||||||
|
'yearly_max': float(yearly_analysis.max_price) if yearly_analysis else 0.0,
|
||||||
|
'threshold_percent': float(hourly_analysis.threshold_percent) if hourly_analysis else 15.0,
|
||||||
|
'lower_threshold': float(hourly_analysis.lower_threshold) if hourly_analysis else 0.0,
|
||||||
|
'upper_threshold': float(hourly_analysis.upper_threshold) if hourly_analysis else 0.0,
|
||||||
|
'last_yearly_update': yearly_analysis.timestamp if yearly_analysis else None,
|
||||||
|
'last_hourly_update': hourly_analysis.timestamp if hourly_analysis else None,
|
||||||
|
'stale_yearly': not yearly_analysis,
|
||||||
|
'stale_hourly': not hourly_analysis,
|
||||||
|
'fetch_error': None, # You can add error tracking later
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = StatusResponseSerializer(status_data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class EventsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for price events.
|
||||||
|
Returns: JSON similar to your Zig version /api/events
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
serializer_class = MarketAnalysisSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Get analyses that are events
|
||||||
|
queryset = MarketAnalysis.objects.filter(is_event=True).order_by('-timestamp')
|
||||||
|
|
||||||
|
# Filter by event type if provided
|
||||||
|
event_type = self.request.query_params.get('event_type', None)
|
||||||
|
if event_type:
|
||||||
|
queryset = queryset.filter(event_type=event_type)
|
||||||
|
|
||||||
|
# Limit to last 365 events by default
|
||||||
|
limit = int(self.request.query_params.get('limit', 365))
|
||||||
|
return queryset[:limit]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def recent(self, request):
|
||||||
|
"""Get recent events (last 24 hours)."""
|
||||||
|
cutoff = timezone.now() - timedelta(hours=24)
|
||||||
|
recent_events = MarketAnalysis.objects.filter(
|
||||||
|
is_event=True,
|
||||||
|
timestamp__gte=cutoff
|
||||||
|
).order_by('-timestamp')
|
||||||
|
|
||||||
|
serializer = self.get_serializer(recent_events, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartDataView(APIView):
|
||||||
|
"""
|
||||||
|
API endpoint for chart data.
|
||||||
|
Returns: JSON similar to your Zig version /api/chart-data
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Get parameters
|
||||||
|
days = int(request.query_params.get('days', 365))
|
||||||
|
cutoff = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Get price data
|
||||||
|
prices = BitcoinPrice.objects.filter(
|
||||||
|
timestamp__gte=cutoff
|
||||||
|
).order_by('timestamp')
|
||||||
|
|
||||||
|
# Get events in same period
|
||||||
|
events = MarketAnalysis.objects.filter(
|
||||||
|
is_event=True,
|
||||||
|
timestamp__gte=cutoff
|
||||||
|
).order_by('timestamp')
|
||||||
|
|
||||||
|
# Prepare chart data
|
||||||
|
chart_data = {
|
||||||
|
'timestamps': [p.timestamp for p in prices],
|
||||||
|
'prices': [float(p.price_usd) for p in prices],
|
||||||
|
'events': MarketAnalysisSerializer(events, many=True).data,
|
||||||
|
'threshold_percent': 15.0, # Default threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = ChartDataSerializer(chart_data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsView(APIView):
|
||||||
|
"""
|
||||||
|
API endpoint for statistics.
|
||||||
|
Returns: JSON similar to your Zig version /api/stats
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
|
||||||
|
# Get analyses
|
||||||
|
hourly = analyzer.get_latest_analysis('hourly')
|
||||||
|
daily = analyzer.get_latest_analysis('daily')
|
||||||
|
yearly = analyzer.get_latest_analysis('yearly')
|
||||||
|
|
||||||
|
# Calculate additional stats
|
||||||
|
total_events = MarketAnalysis.objects.filter(is_event=True).count()
|
||||||
|
total_prices = BitcoinPrice.objects.count()
|
||||||
|
|
||||||
|
# Get recent price for volatility calculation
|
||||||
|
recent_prices = BitcoinPrice.objects.order_by('-timestamp')[:100]
|
||||||
|
if len(recent_prices) > 1:
|
||||||
|
# Simple volatility calculation
|
||||||
|
prices = [float(p.price_usd) for p in recent_prices]
|
||||||
|
avg_price = sum(prices) / len(prices)
|
||||||
|
price_changes = []
|
||||||
|
for i in range(1, len(prices)):
|
||||||
|
change = (prices[i] - prices[i-1]) / prices[i-1] * 100
|
||||||
|
price_changes.append(abs(change))
|
||||||
|
volatility = sum(price_changes) / len(price_changes) if price_changes else 0
|
||||||
|
else:
|
||||||
|
volatility = 0
|
||||||
|
|
||||||
|
stats_data = {
|
||||||
|
'yearly_average': float(yearly.average_price) if yearly else 0.0,
|
||||||
|
'yearly_min': float(yearly.min_price) if yearly else 0.0,
|
||||||
|
'yearly_max': float(yearly.max_price) if yearly else 0.0,
|
||||||
|
'threshold_percent': float(hourly.threshold_percent) if hourly else 15.0,
|
||||||
|
'current_price': float(hourly.current_price) if hourly else 0.0,
|
||||||
|
'current_status': hourly.status if hourly else 'neutral',
|
||||||
|
'total_events': total_events,
|
||||||
|
'last_yearly_update': yearly.timestamp if yearly else None,
|
||||||
|
'last_hourly_update': hourly.timestamp if hourly else None,
|
||||||
|
'volatility_percent': round(volatility, 2),
|
||||||
|
'total_price_records': total_prices,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(stats_data)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckView(APIView):
|
||||||
|
"""
|
||||||
|
API endpoint for health check.
|
||||||
|
Returns: JSON similar to your Zig version /health
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
|
||||||
|
# Check if we have recent data
|
||||||
|
recent_price = BitcoinPrice.objects.order_by('-timestamp').first()
|
||||||
|
recent_analysis = MarketAnalysis.objects.order_by('-timestamp').first()
|
||||||
|
|
||||||
|
# Determine health status
|
||||||
|
is_healthy = True
|
||||||
|
checks = {}
|
||||||
|
|
||||||
|
if recent_price:
|
||||||
|
price_age = (timezone.now() - recent_price.timestamp).total_seconds()
|
||||||
|
checks['price_age_seconds'] = price_age
|
||||||
|
checks['price_fresh'] = price_age < 3600 # Less than 1 hour old
|
||||||
|
if price_age >= 3600:
|
||||||
|
is_healthy = False
|
||||||
|
else:
|
||||||
|
checks['price_fresh'] = False
|
||||||
|
is_healthy = False
|
||||||
|
|
||||||
|
if recent_analysis:
|
||||||
|
analysis_age = (timezone.now() - recent_analysis.timestamp).total_seconds()
|
||||||
|
checks['analysis_age_seconds'] = analysis_age
|
||||||
|
checks['analysis_fresh'] = analysis_age < 7200 # Less than 2 hours old
|
||||||
|
if analysis_age >= 7200:
|
||||||
|
is_healthy = False
|
||||||
|
else:
|
||||||
|
checks['analysis_fresh'] = False
|
||||||
|
is_healthy = False
|
||||||
|
|
||||||
|
checks['database_connected'] = True
|
||||||
|
checks['has_prices'] = BitcoinPrice.objects.exists()
|
||||||
|
checks['has_analyses'] = MarketAnalysis.objects.exists()
|
||||||
|
|
||||||
|
health_data = {
|
||||||
|
'status': 'healthy' if is_healthy else 'unhealthy',
|
||||||
|
'timestamp': timezone.now(),
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = HealthCheckSerializer(health_data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class BitcoinPriceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for Bitcoin price data.
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
queryset = BitcoinPrice.objects.all().order_by('-timestamp')
|
||||||
|
serializer_class = BitcoinPriceSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter by date range
|
||||||
|
start_date = self.request.query_params.get('start_date', None)
|
||||||
|
end_date = self.request.query_params.get('end_date', None)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
queryset = queryset.filter(timestamp__gte=start_date)
|
||||||
|
if end_date:
|
||||||
|
queryset = queryset.filter(timestamp__lte=end_date)
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
limit = self.request.query_params.get('limit', None)
|
||||||
|
if limit:
|
||||||
|
queryset = queryset[:int(limit)]
|
||||||
|
|
||||||
|
return queryset
|
||||||
BIN
celerybeat-schedule-shm
Normal file
BIN
celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
celerybeat-schedule-wal
Normal file
BIN
celerybeat-schedule-wal
Normal file
Binary file not shown.
3
config/__init__.py
Normal file
3
config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
16
config/asgi.py
Normal file
16
config/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for config project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
68
config/celery.py
Normal file
68
config/celery.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
# Set the default Django settings module
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
app = Celery('bitcoin_monitor')
|
||||||
|
|
||||||
|
# Using a string here means the worker doesn't have to serialize
|
||||||
|
# the configuration object to child processes
|
||||||
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|
||||||
|
# Load task modules from all registered Django apps
|
||||||
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
# Configure periodic tasks
|
||||||
|
|
||||||
|
app.conf.beat_schedule = {
|
||||||
|
'fetch-bitcoin-price-every-5-minutes': {
|
||||||
|
'task': 'monitor.tasks.fetch_bitcoin_price_task',
|
||||||
|
'schedule': 300.0, # 300 seconds = 5 minutes
|
||||||
|
'options': {
|
||||||
|
'expires': 300,
|
||||||
|
'retry': True,
|
||||||
|
'retry_policy': {
|
||||||
|
'max_retries': 3,
|
||||||
|
'interval_start': 60,
|
||||||
|
'interval_step': 60,
|
||||||
|
'interval_max': 300,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'run-hourly-analysis-every-hour': {
|
||||||
|
'task': 'monitor.tasks.run_hourly_analysis_task',
|
||||||
|
'schedule': 3600.0, # 3600 seconds = 1 hour
|
||||||
|
'options': {
|
||||||
|
'expires': 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'send-daily-digest-at-8am': {
|
||||||
|
'task': 'monitor.tasks.send_daily_digest_task',
|
||||||
|
'schedule': crontab(hour=8, minute=0), # 8 AM daily
|
||||||
|
'options': {
|
||||||
|
'expires': 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'cleanup-old-data-daily': {
|
||||||
|
'task': 'monitor.tasks.cleanup_old_data_task',
|
||||||
|
'schedule': crontab(hour=0, minute=0), # Midnight daily
|
||||||
|
},
|
||||||
|
'check-system-health-every-10-minutes': {
|
||||||
|
'task': 'monitor.tasks.check_system_health_task',
|
||||||
|
'schedule': 600.0, # 600 seconds = 10 minutes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schedule for daily/yearly tasks (disabled for now as per your request)
|
||||||
|
# app.conf.beat_schedule.update({
|
||||||
|
# 'run-yearly-analysis-daily': {
|
||||||
|
# 'task': 'monitor.tasks.run_yearly_analysis_task',
|
||||||
|
# 'schedule': crontab(hour=0, minute=0), # Midnight
|
||||||
|
# },
|
||||||
|
# })
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def debug_task(self):
|
||||||
|
print(f'Request: {self.request!r}')
|
||||||
99
config/services/data_fetcher.py
Normal file
99
config/services/data_fetcher.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CoinGeckoFetcher:
|
||||||
|
"""Fetches Bitcoin data from CoinGecko API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = "https://api.coingecko.com/api/v3"
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'BitcoinMonitor/1.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Optional API key for higher rate limits
|
||||||
|
api_key = settings.BITCOIN_MONITOR.get('COINGECKO_API_KEY')
|
||||||
|
if api_key:
|
||||||
|
self.session.headers['x-cg-pro-api-key'] = api_key
|
||||||
|
|
||||||
|
def fetch_current_price(self):
|
||||||
|
"""Fetch current Bitcoin price."""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/simple/price"
|
||||||
|
params = {
|
||||||
|
'ids': 'bitcoin',
|
||||||
|
'vs_currencies': 'usd',
|
||||||
|
'include_market_cap': 'true',
|
||||||
|
'include_24hr_vol': 'true',
|
||||||
|
'include_last_updated_at': 'true',
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Fetching current price from {url}")
|
||||||
|
response = self.session.get(url, params=params, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if 'bitcoin' not in data:
|
||||||
|
logger.error("Bitcoin data not found in response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
btc_data = data['bitcoin']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'timestamp': datetime.fromtimestamp(
|
||||||
|
btc_data.get('last_updated_at', datetime.now(timezone.utc).timestamp()),
|
||||||
|
timezone.utc
|
||||||
|
),
|
||||||
|
'price_usd': btc_data['usd'],
|
||||||
|
'market_cap': btc_data.get('usd_market_cap'),
|
||||||
|
'volume': btc_data.get('usd_24h_vol'),
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error fetching current price: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching current price: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_price_history(self, days=30):
|
||||||
|
"""Fetch historical price data (for future use)."""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/coins/bitcoin/market_chart"
|
||||||
|
params = {
|
||||||
|
'vs_currency': 'usd',
|
||||||
|
'days': days,
|
||||||
|
'interval': 'daily',
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Fetching {days} days of price history")
|
||||||
|
response = self.session.get(url, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
prices = []
|
||||||
|
for point in data.get('prices', []):
|
||||||
|
prices.append({
|
||||||
|
'timestamp': datetime.fromtimestamp(point[0] / 1000, timezone.utc),
|
||||||
|
'price': point[1],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'prices': prices,
|
||||||
|
'total_points': len(prices),
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error fetching price history: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching price history: {e}")
|
||||||
|
return None
|
||||||
175
config/settings.py
Normal file
175
config/settings.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
Django settings for config project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.0.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-mb+4lzhzvs3cu^hb-!n0me7fm@&xc6an4s80bmm6ad71lq23&o'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
"rest_framework",
|
||||||
|
"django_celery_results",
|
||||||
|
"monitor",
|
||||||
|
"api",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# REST Framework settings
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.AllowAny', # Allow public access for now
|
||||||
|
],
|
||||||
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
|
'rest_framework.renderers.JSONRenderer',
|
||||||
|
'rest_framework.renderers.BrowsableAPIRenderer', # Nice API browser
|
||||||
|
],
|
||||||
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
|
'PAGE_SIZE': 100,
|
||||||
|
'DEFAULT_THROTTLE_CLASSES': [
|
||||||
|
'rest_framework.throttling.AnonRateThrottle',
|
||||||
|
'rest_framework.throttling.UserRateThrottle'
|
||||||
|
],
|
||||||
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
|
'anon': '100/hour',
|
||||||
|
'user': '1000/hour'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Redis as broker
|
||||||
|
CELERY_RESULT_BACKEND = 'django-db' # Use Django database for results
|
||||||
|
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||||
|
CELERY_TASK_SERIALIZER = 'json'
|
||||||
|
CELERY_RESULT_SERIALIZER = 'json'
|
||||||
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
|
||||||
|
BITCOIN_MONITOR = {
|
||||||
|
'THRESHOLD_PERCENT': 15.0,
|
||||||
|
'UPDATE_INTERVAL_MINUTES': 5, # Fetch every 5 minutes for testing
|
||||||
|
'COINGECKO_API_KEY': '', # Optional API key for higher rate limits
|
||||||
|
}
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
# Email settings
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com')
|
||||||
|
EMAIL_PORT = int(os.getenv('EMAIL_PORT', 587))
|
||||||
|
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True') == 'True'
|
||||||
|
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'Bitcoin Monitor <noreply@bitcoin-monitor.com>')
|
||||||
|
|
||||||
|
# Site domain for email links
|
||||||
|
SITE_DOMAIN = os.getenv('SITE_DOMAIN', 'localhost:8000')
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
NOTIFICATION_SETTINGS = {
|
||||||
|
'EVENT_COOLDOWN_MINUTES': 60, # Don't send same event alerts within 60 minutes
|
||||||
|
'MAX_RETRIES': 3,
|
||||||
|
'DAILY_DIGEST_HOUR': 8, # 8 AM
|
||||||
|
'TEST_EMAIL_RECIPIENT': 'ali.c.zeybek@gmail.com',
|
||||||
|
}
|
||||||
|
STATIC_URL = 'static/'
|
||||||
229
config/tasks.py
Normal file
229
config/tasks.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis, SystemStatus
|
||||||
|
from monitor.services.analyzer import MarketAnalyzer
|
||||||
|
from monitor.services.data_fetcher import CoinGeckoFetcher
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def fetch_bitcoin_price_task(self):
|
||||||
|
"""
|
||||||
|
Fetch current Bitcoin price and save to database.
|
||||||
|
Runs every 5 minutes by default.
|
||||||
|
"""
|
||||||
|
logger.info("Starting Bitcoin price fetch task...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetcher = CoinGeckoFetcher()
|
||||||
|
|
||||||
|
# Fetch current price
|
||||||
|
price_data = fetcher.fetch_current_price()
|
||||||
|
|
||||||
|
if not price_data:
|
||||||
|
logger.error("Failed to fetch price data")
|
||||||
|
raise Exception("No price data received")
|
||||||
|
|
||||||
|
# Save to database in transaction
|
||||||
|
with transaction.atomic():
|
||||||
|
bitcoin_price = BitcoinPrice.objects.create(
|
||||||
|
timestamp=price_data['timestamp'],
|
||||||
|
price_usd=price_data['price_usd'],
|
||||||
|
volume=price_data.get('volume'),
|
||||||
|
market_cap=price_data.get('market_cap'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update system status
|
||||||
|
SystemStatus.objects.create(
|
||||||
|
current_price=bitcoin_price.price_usd,
|
||||||
|
last_hourly_update=timezone.now(),
|
||||||
|
last_successful_fetch=timezone.now(),
|
||||||
|
is_stale=False,
|
||||||
|
is_healthy=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Successfully fetched and saved Bitcoin price: ${price_data['price_usd']}")
|
||||||
|
|
||||||
|
# Trigger analysis if it's been more than 55 minutes since last analysis
|
||||||
|
# or if this is a significant price change
|
||||||
|
last_analysis = MarketAnalysis.objects.filter(
|
||||||
|
period='hourly'
|
||||||
|
).order_by('-timestamp').first()
|
||||||
|
|
||||||
|
should_analyze = False
|
||||||
|
if not last_analysis:
|
||||||
|
should_analyze = True
|
||||||
|
else:
|
||||||
|
time_since_analysis = timezone.now() - last_analysis.timestamp
|
||||||
|
if time_since_analysis.total_seconds() > 3300: # 55 minutes
|
||||||
|
should_analyze = True
|
||||||
|
|
||||||
|
if should_analyze:
|
||||||
|
# Run analysis in separate task
|
||||||
|
run_hourly_analysis_task.delay()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'price': float(price_data['price_usd']),
|
||||||
|
'timestamp': price_data['timestamp'].isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in fetch_bitcoin_price_task: {e}")
|
||||||
|
|
||||||
|
# Update system status with error
|
||||||
|
SystemStatus.objects.create(
|
||||||
|
last_error=str(e),
|
||||||
|
is_stale=True,
|
||||||
|
is_healthy=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retry the task
|
||||||
|
self.retry(exc=e, countdown=60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def run_hourly_analysis_task():
|
||||||
|
"""
|
||||||
|
Run hourly market analysis.
|
||||||
|
Runs every hour by default.
|
||||||
|
"""
|
||||||
|
logger.info("Starting hourly analysis task...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Run hourly analysis
|
||||||
|
analysis = analyzer.analyze_market('hourly')
|
||||||
|
|
||||||
|
if analysis:
|
||||||
|
logger.info(f"Hourly analysis completed: {analysis.status} at ${analysis.current_price}")
|
||||||
|
|
||||||
|
# Check if this is an event and log it
|
||||||
|
if analysis.is_event:
|
||||||
|
logger.warning(
|
||||||
|
f"Market event detected: {analysis.event_type} "
|
||||||
|
f"at ${analysis.current_price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'analysis_id': analysis.id,
|
||||||
|
'status': analysis.status,
|
||||||
|
'price': float(analysis.current_price),
|
||||||
|
'is_event': analysis.is_event,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning("Hourly analysis returned no results")
|
||||||
|
return {'success': False, 'error': 'No analysis results'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in run_hourly_analysis_task: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_old_data_task():
|
||||||
|
"""
|
||||||
|
Clean up old data to keep database size manageable.
|
||||||
|
Runs once a day.
|
||||||
|
"""
|
||||||
|
logger.info("Starting data cleanup task...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Keep only last 30 days of price data for performance
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=30)
|
||||||
|
deleted_count, _ = BitcoinPrice.objects.filter(
|
||||||
|
timestamp__lt=cutoff_date
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# Keep only last 1000 system status entries
|
||||||
|
status_entries = SystemStatus.objects.all().order_by('-timestamp')
|
||||||
|
if status_entries.count() > 1000:
|
||||||
|
status_to_delete = status_entries[1000:]
|
||||||
|
deleted_status_count, _ = status_to_delete.delete()
|
||||||
|
|
||||||
|
# Keep only last 365 analyses
|
||||||
|
analyses = MarketAnalysis.objects.all().order_by('-timestamp')
|
||||||
|
if analyses.count() > 365:
|
||||||
|
analyses_to_delete = analyses[365:]
|
||||||
|
deleted_analyses_count, _ = analyses_to_delete.delete()
|
||||||
|
|
||||||
|
logger.info(f"Cleanup completed. Deleted {deleted_count} old price records.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'deleted_prices': deleted_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in cleanup_old_data_task: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def check_system_health_task():
|
||||||
|
"""
|
||||||
|
Check system health and log status.
|
||||||
|
Runs every 10 minutes.
|
||||||
|
"""
|
||||||
|
logger.info("Checking system health...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if we have recent data
|
||||||
|
recent_price = BitcoinPrice.objects.order_by('-timestamp').first()
|
||||||
|
recent_analysis = MarketAnalysis.objects.order_by('-timestamp').first()
|
||||||
|
|
||||||
|
is_healthy = True
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
if not recent_price:
|
||||||
|
issues.append("No price data available")
|
||||||
|
is_healthy = False
|
||||||
|
else:
|
||||||
|
price_age = (timezone.now() - recent_price.timestamp).total_seconds()
|
||||||
|
if price_age > 3600: # More than 1 hour old
|
||||||
|
issues.append(f"Price data is {price_age/60:.0f} minutes old")
|
||||||
|
is_healthy = False
|
||||||
|
|
||||||
|
if not recent_analysis:
|
||||||
|
issues.append("No analysis data available")
|
||||||
|
is_healthy = False
|
||||||
|
else:
|
||||||
|
analysis_age = (timezone.now() - recent_analysis.timestamp).total_seconds()
|
||||||
|
if analysis_age > 7200: # More than 2 hours old
|
||||||
|
issues.append(f"Analysis data is {analysis_age/3600:.1f} hours old")
|
||||||
|
is_healthy = False
|
||||||
|
|
||||||
|
# Log health status
|
||||||
|
if is_healthy:
|
||||||
|
logger.info("System is healthy")
|
||||||
|
else:
|
||||||
|
logger.warning(f"System has issues: {', '.join(issues)}")
|
||||||
|
|
||||||
|
# Update system status
|
||||||
|
SystemStatus.objects.create(
|
||||||
|
is_healthy=is_healthy,
|
||||||
|
last_error=', '.join(issues) if issues else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'healthy': is_healthy,
|
||||||
|
'issues': issues,
|
||||||
|
'last_price_time': recent_price.timestamp.isoformat() if recent_price else None,
|
||||||
|
'last_analysis_time': recent_analysis.timestamp.isoformat() if recent_analysis else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in check_system_health_task: {e}")
|
||||||
|
return {'healthy': False, 'error': str(e)}
|
||||||
52
config/templates/dashboard.html
Normal file
52
config/templates/dashboard.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Bitcoin Monitor</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>₿ Bitcoin Monitor</h1>
|
||||||
|
<p>Welcome to your Bitcoin monitoring dashboard!</p>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<h2>Status: Running</h2>
|
||||||
|
<p>This is a simple Django app for monitoring Bitcoin prices.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Next Steps:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Connect to Bitcoin API</li>
|
||||||
|
<li>Display real-time prices</li>
|
||||||
|
<li>Add price charts</li>
|
||||||
|
<li>Set up alerts</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Check the API: <a href="/">Hello World</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
config/urls.py
Normal file
30
config/urls.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for config project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from . import views
|
||||||
|
from monitor import views as monitor_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', monitor_views.bitcoin_data, name='bitcoin_data'),
|
||||||
|
path('dashboard/', views.dashboard), # Dashboard URL
|
||||||
|
path('fetch-price/', monitor_views.fetch_bitcoin_price, name='fetch_price'),
|
||||||
|
path('analysis/run/', monitor_views.run_analysis, name='run_analysis'),
|
||||||
|
path('analysis/views/', monitor_views.view_analysis, name='view_analysis'),
|
||||||
|
path('api/', include('api.urls')),
|
||||||
|
]
|
||||||
9
config/views.py
Normal file
9
config/views.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# config/views.py
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
def hello_world(request):
|
||||||
|
return HttpResponse("Hello, Bitcoin Monitor World!")
|
||||||
|
|
||||||
|
def dashboard(request):
|
||||||
|
return render(request, 'dashboard.html')
|
||||||
16
config/wsgi.py
Normal file
16
config/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for config project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
2
mailout
Normal file
2
mailout
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Sending test email to ali.c.zeybek@gmail.com...
|
||||||
|
Failed to send test email: (534, b'5.7.9 Application-specific password required. For more information, go to\n5.7.9 https://support.google.com/mail/?p=InvalidSecondFactor 4fb4d7f45d1cf-647b2eda768sm16980299a12.8 - gsmtp')
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
0
monitor/__init__.py
Normal file
0
monitor/__init__.py
Normal file
130
monitor/admin.py
Normal file
130
monitor/admin.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import BitcoinPrice, MarketAnalysis, SystemStatus, NotificationPreference, EmailNotification
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BitcoinPrice)
|
||||||
|
class BitcoinPriceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('timestamp', 'price_usd', 'volume', 'market_cap')
|
||||||
|
list_filter = ('timestamp',)
|
||||||
|
search_fields = ('price_usd',)
|
||||||
|
date_hierarchy = 'timestamp'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MarketAnalysis)
|
||||||
|
class MarketAnalysisAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('timestamp', 'period', 'status', 'current_price', 'average_price')
|
||||||
|
list_filter = ('period', 'status', 'is_event')
|
||||||
|
search_fields = ('status', 'event_type')
|
||||||
|
date_hierarchy = 'timestamp'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Info', {
|
||||||
|
'fields': ('timestamp', 'period', 'status')
|
||||||
|
}),
|
||||||
|
('Prices', {
|
||||||
|
'fields': ('current_price', 'average_price', 'min_price', 'max_price')
|
||||||
|
}),
|
||||||
|
('Analysis', {
|
||||||
|
'fields': ('threshold_percent', 'lower_threshold', 'upper_threshold')
|
||||||
|
}),
|
||||||
|
('Events', {
|
||||||
|
'fields': ('is_event', 'event_type')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SystemStatus)
|
||||||
|
class SystemStatusAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('timestamp', 'is_healthy', 'is_stale', 'current_price')
|
||||||
|
list_filter = ('is_healthy', 'is_stale')
|
||||||
|
readonly_fields = ('timestamp',)
|
||||||
|
date_hierarchy = 'timestamp'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Status', {
|
||||||
|
'fields': ('timestamp', 'is_healthy', 'is_stale')
|
||||||
|
}),
|
||||||
|
('Updates', {
|
||||||
|
'fields': ('last_hourly_update', 'last_successful_fetch')
|
||||||
|
}),
|
||||||
|
('Current State', {
|
||||||
|
'fields': ('current_price',)
|
||||||
|
}),
|
||||||
|
('Errors', {
|
||||||
|
'fields': ('last_error',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NotificationPreference)
|
||||||
|
class NotificationPreferenceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('email_address', 'is_active', 'receive_event_alerts',
|
||||||
|
'receive_system_alerts', 'receive_daily_digest', 'created_at')
|
||||||
|
list_filter = ('is_active', 'receive_event_alerts', 'receive_system_alerts',
|
||||||
|
'receive_daily_digest')
|
||||||
|
search_fields = ('email_address',)
|
||||||
|
list_editable = ('is_active', 'receive_event_alerts', 'receive_system_alerts',
|
||||||
|
'receive_daily_digest')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Email Settings', {
|
||||||
|
'fields': ('email_address', 'is_active')
|
||||||
|
}),
|
||||||
|
('Notification Types', {
|
||||||
|
'fields': ('receive_event_alerts', 'receive_system_alerts', 'receive_daily_digest'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EmailNotification)
|
||||||
|
class EmailNotificationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('recipient', 'subject', 'notification_type', 'status',
|
||||||
|
'sent_at', 'created_at')
|
||||||
|
list_filter = ('notification_type', 'status', 'recipient')
|
||||||
|
search_fields = ('recipient', 'subject', 'error_message')
|
||||||
|
readonly_fields = ('created_at', 'sent_at', 'retry_count')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Info', {
|
||||||
|
'fields': ('recipient', 'subject', 'notification_type', 'status')
|
||||||
|
}),
|
||||||
|
('Content', {
|
||||||
|
'fields': ('content_text', 'content_html'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Delivery Info', {
|
||||||
|
'fields': ('sent_at', 'retry_count', 'error_message')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ['resend_failed_emails']
|
||||||
|
|
||||||
|
def resend_failed_emails(self, request, queryset):
|
||||||
|
"""Resend failed emails."""
|
||||||
|
from monitor.services.email_service import EmailService
|
||||||
|
email_service = EmailService()
|
||||||
|
|
||||||
|
resend_count = 0
|
||||||
|
for email in queryset.filter(status='failed'):
|
||||||
|
try:
|
||||||
|
# Create a copy of the email and send
|
||||||
|
email_service._send_single_email(email, '', {})
|
||||||
|
resend_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
self.message_user(request, f"Failed to resend to {email.recipient}: {e}", level='error')
|
||||||
|
|
||||||
|
self.message_user(request, f"Resent {resend_count} email(s)")
|
||||||
|
|
||||||
|
resend_failed_emails.short_description = "Resend selected failed emails"
|
||||||
5
monitor/apps.py
Normal file
5
monitor/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorConfig(AppConfig):
|
||||||
|
name = 'monitor'
|
||||||
156
monitor/management/commands/check_data_quality.py
Normal file
156
monitor/management/commands/check_data_quality.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from monitor.services.historical_data import HistoricalDataFetcher
|
||||||
|
from monitor.models import BitcoinPrice
|
||||||
|
from django.db.models import Min, Max, Count
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Check quality and statistics of Bitcoin price data in database'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--fetch-sample',
|
||||||
|
action='store_true',
|
||||||
|
help='Fetch sample data for comparison'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.HTTP_INFO("Bitcoin Data Quality Check"))
|
||||||
|
self.stdout.write("=" * 50)
|
||||||
|
|
||||||
|
# Get database statistics
|
||||||
|
total_records = BitcoinPrice.objects.count()
|
||||||
|
|
||||||
|
if total_records == 0:
|
||||||
|
self.stdout.write(self.style.ERROR("No Bitcoin price data in database."))
|
||||||
|
self.stdout.write("Run: python manage.py load_historical_data")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get date range
|
||||||
|
date_range = BitcoinPrice.objects.aggregate(
|
||||||
|
earliest=Min('timestamp'),
|
||||||
|
latest=Max('timestamp')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate time span
|
||||||
|
if date_range['earliest'] and date_range['latest']:
|
||||||
|
time_span = date_range['latest'] - date_range['earliest']
|
||||||
|
days_span = time_span.days
|
||||||
|
|
||||||
|
self.stdout.write(f"Data range: {date_range['earliest'].strftime('%Y-%m-%d')} "
|
||||||
|
f"to {date_range['latest'].strftime('%Y-%m-%d')}")
|
||||||
|
self.stdout.write(f"Time span: {days_span} days")
|
||||||
|
|
||||||
|
self.stdout.write(f"Total records: {total_records}")
|
||||||
|
|
||||||
|
# Calculate records per day
|
||||||
|
if days_span > 0:
|
||||||
|
records_per_day = total_records / days_span
|
||||||
|
self.stdout.write(f"Records per day: {records_per_day:.2f}")
|
||||||
|
|
||||||
|
if records_per_day < 0.9:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("⚠️ Less than 1 record per day - data may be incomplete")
|
||||||
|
)
|
||||||
|
elif records_per_day > 24:
|
||||||
|
self.stdout.write("📈 More than hourly data - good coverage")
|
||||||
|
else:
|
||||||
|
self.stdout.write("📊 Daily data coverage")
|
||||||
|
|
||||||
|
# Check for missing values
|
||||||
|
missing_volume = BitcoinPrice.objects.filter(volume__isnull=True).count()
|
||||||
|
missing_market_cap = BitcoinPrice.objects.filter(market_cap__isnull=True).count()
|
||||||
|
|
||||||
|
if missing_volume > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Missing volume data: {missing_volume} records ({missing_volume/total_records*100:.1f}%)")
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_market_cap > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Missing market cap: {missing_market_cap} records ({missing_market_cap/total_records*100:.1f}%)")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get price statistics
|
||||||
|
prices = BitcoinPrice.objects.all().order_by('timestamp')
|
||||||
|
price_list = [float(p.price_usd) for p in prices]
|
||||||
|
|
||||||
|
if price_list:
|
||||||
|
min_price = min(price_list)
|
||||||
|
max_price = max(price_list)
|
||||||
|
avg_price = sum(price_list) / len(price_list)
|
||||||
|
|
||||||
|
self.stdout.write("\n" + self.style.SUCCESS("Price Statistics"))
|
||||||
|
self.stdout.write("-" * 30)
|
||||||
|
self.stdout.write(f"Minimum price: ${min_price:.2f}")
|
||||||
|
self.stdout.write(f"Maximum price: ${max_price:.2f}")
|
||||||
|
self.stdout.write(f"Average price: ${avg_price:.2f}")
|
||||||
|
self.stdout.write(f"Price range: ${max_price - min_price:.2f} "
|
||||||
|
f"({((max_price - min_price) / min_price * 100):.1f}%)")
|
||||||
|
|
||||||
|
# Check for time gaps
|
||||||
|
time_gaps = []
|
||||||
|
prev_timestamp = None
|
||||||
|
|
||||||
|
for price in prices.order_by('timestamp'):
|
||||||
|
if prev_timestamp:
|
||||||
|
gap_hours = (price.timestamp - prev_timestamp).total_seconds() / 3600
|
||||||
|
if gap_hours > 24: # More than 1 day gap
|
||||||
|
time_gaps.append({
|
||||||
|
'from': prev_timestamp,
|
||||||
|
'to': price.timestamp,
|
||||||
|
'gap_days': gap_hours / 24,
|
||||||
|
})
|
||||||
|
prev_timestamp = price.timestamp
|
||||||
|
|
||||||
|
if time_gaps:
|
||||||
|
self.stdout.write("\n" + self.style.WARNING("Time Gaps Detected"))
|
||||||
|
self.stdout.write("-" * 30)
|
||||||
|
for gap in time_gaps[:3]: # Show first 3 gaps
|
||||||
|
self.stdout.write(
|
||||||
|
f"Gap of {gap['gap_days']:.1f} days from "
|
||||||
|
f"{gap['from'].strftime('%Y-%m-%d')} to {gap['to'].strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(time_gaps) > 3:
|
||||||
|
self.stdout.write(f"... and {len(time_gaps) - 3} more gaps")
|
||||||
|
|
||||||
|
# Compare with fresh data if requested
|
||||||
|
if options['fetch_sample']:
|
||||||
|
self.stdout.write("\n" + self.style.INFO("Fetching sample data for comparison..."))
|
||||||
|
|
||||||
|
fetcher = HistoricalDataFetcher()
|
||||||
|
sample_data = fetcher.fetch_historical_data(days=30)
|
||||||
|
|
||||||
|
if sample_data:
|
||||||
|
self.stdout.write(f"Sample data points: {len(sample_data)}")
|
||||||
|
|
||||||
|
sample_prices = [d['price_usd'] for d in sample_data]
|
||||||
|
sample_min = min(sample_prices)
|
||||||
|
sample_max = max(sample_prices)
|
||||||
|
sample_avg = sum(sample_prices) / len(sample_prices)
|
||||||
|
|
||||||
|
self.stdout.write(f"Sample min: ${sample_min:.2f}")
|
||||||
|
self.stdout.write(f"Sample max: ${sample_max:.2f}")
|
||||||
|
self.stdout.write(f"Sample avg: ${sample_avg:.2f}")
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
self.stdout.write("\n" + self.style.HTTP_INFO("Recommendations"))
|
||||||
|
self.stdout.write("-" * 30)
|
||||||
|
|
||||||
|
if total_records < 100:
|
||||||
|
self.stdout.write("1. Load more data: python manage.py load_historical_data --days 365")
|
||||||
|
|
||||||
|
if missing_volume > total_records * 0.5:
|
||||||
|
self.stdout.write("2. Consider fetching data with volume information")
|
||||||
|
|
||||||
|
if time_gaps:
|
||||||
|
self.stdout.write("3. Consider filling time gaps with additional data")
|
||||||
|
|
||||||
|
if total_records >= 100 and not time_gaps and missing_volume < total_records * 0.1:
|
||||||
|
self.stdout.write("✅ Data quality looks good!")
|
||||||
|
|
||||||
|
self.stdout.write("\n" + self.style.SUCCESS("Quality check complete!"))
|
||||||
211
monitor/management/commands/load_historical_data.py
Normal file
211
monitor/management/commands/load_historical_data.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from monitor.services.historical_data import HistoricalDataFetcher
|
||||||
|
from monitor.services.analyzer import MarketAnalyzer
|
||||||
|
from monitor.services.email_service import EmailService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Load historical Bitcoin price data into the database'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--days',
|
||||||
|
type=int,
|
||||||
|
default=365,
|
||||||
|
help='Number of days of historical data to fetch (default: 365)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='Clear existing data before loading new data'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--generate-test',
|
||||||
|
action='store_true',
|
||||||
|
help='Generate synthetic test data instead of fetching real data'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--test-days',
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help='Days of test data to generate (default: 30)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--analyze',
|
||||||
|
action='store_true',
|
||||||
|
help='Run market analysis on historical data after loading'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--notify',
|
||||||
|
action='store_true',
|
||||||
|
help='Send email notification when data loading is complete'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--quality-check',
|
||||||
|
action='store_true',
|
||||||
|
help='Perform data quality analysis'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.HTTP_INFO("Bitcoin Historical Data Loader"))
|
||||||
|
self.stdout.write("=" * 50)
|
||||||
|
|
||||||
|
days = options['days']
|
||||||
|
clear_existing = options['clear']
|
||||||
|
generate_test = options['generate_test']
|
||||||
|
test_days = options['test_days']
|
||||||
|
analyze = options['analyze']
|
||||||
|
notify = options['notify']
|
||||||
|
quality_check = options['quality_check']
|
||||||
|
|
||||||
|
fetcher = HistoricalDataFetcher()
|
||||||
|
|
||||||
|
if generate_test:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Generating {test_days} days of synthetic test data..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
historical_data = fetcher.generate_test_data(days=test_days)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
(f"Fetching {days} days of historical Bitcoin data...")
|
||||||
|
)
|
||||||
|
historical_data = fetcher.fetch_historical_data(days=days)
|
||||||
|
|
||||||
|
if not historical_data:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR("No data fetched. Check your internet connection and API status.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Perform quality check if requested
|
||||||
|
if quality_check:
|
||||||
|
self.stdout.write(self.style.INFO("Performing data quality analysis..."))
|
||||||
|
quality_metrics = fetcher.analyze_historical_data_quality(historical_data)
|
||||||
|
|
||||||
|
self.stdout.write(f"Total data points: {quality_metrics['total_points']}")
|
||||||
|
self.stdout.write(
|
||||||
|
f"Date range: {quality_metrics['date_range']['start'].strftime('%Y-%m-%d')} "
|
||||||
|
f"to {quality_metrics['date_range']['end'].strftime('%Y-%m-%d')} "
|
||||||
|
f"({quality_metrics['date_range']['days']} days)"
|
||||||
|
)
|
||||||
|
|
||||||
|
price_stats = quality_metrics['price_stats']
|
||||||
|
self.stdout.write(f"Price range: ${price_stats['min']:.2f} - ${price_stats['max']:.2f}")
|
||||||
|
self.stdout.write(f"Average price: ${price_stats['average']:.2f}")
|
||||||
|
|
||||||
|
if quality_metrics['data_quality']['missing_prices'] > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Missing prices: {quality_metrics['data_quality']['missing_prices']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if quality_metrics['data_quality']['time_gaps'] > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Time gaps found: {quality_metrics['data_quality']['time_gaps']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for suggestion in quality_metrics.get('suggestions', []):
|
||||||
|
self.stdout.write(f"💡 {suggestion}")
|
||||||
|
|
||||||
|
# Save data to database
|
||||||
|
self.stdout.write(("Saving data to database..."))
|
||||||
|
|
||||||
|
save_stats = fetcher.save_historical_data(
|
||||||
|
historical_data=historical_data,
|
||||||
|
clear_existing=clear_existing
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display save statistics
|
||||||
|
self.stdout.write("\n" + self.style.SUCCESS("Data Load Summary"))
|
||||||
|
self.stdout.write("-" * 30)
|
||||||
|
self.stdout.write(f"Total records processed: {save_stats['total']}")
|
||||||
|
self.stdout.write(f"New records saved: {save_stats['saved']}")
|
||||||
|
self.stdout.write(f"Existing records skipped: {save_stats['skipped']}")
|
||||||
|
self.stdout.write(f"Errors: {save_stats['errors']}")
|
||||||
|
|
||||||
|
if save_stats['errors'] > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"⚠️ {save_stats['errors']} records had errors and were not saved")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run analysis if requested
|
||||||
|
if analyze and save_stats['saved'] > 0:
|
||||||
|
self.stdout.write("\n" + ("Running market analysis on historical data..."))
|
||||||
|
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
analysis_count = 0
|
||||||
|
|
||||||
|
# Run analysis for different time periods
|
||||||
|
for period in ['hourly', 'daily', 'weekly', 'yearly']:
|
||||||
|
analysis = analyzer.analyze_market(period)
|
||||||
|
if analysis:
|
||||||
|
analysis_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
f" {period.capitalize()} analysis: {analysis.status} at ${analysis.current_price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Completed {analysis_count} market analyses")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notification if requested
|
||||||
|
if notify:
|
||||||
|
self.stdout.write("\n" + self.style.INFO("Sending completion notification..."))
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_service = EmailService()
|
||||||
|
subject = f"✅ Historical Data Loaded: {save_stats['saved']} records"
|
||||||
|
|
||||||
|
# Create a simple notification
|
||||||
|
email_service.send_system_alert(
|
||||||
|
alert_title="Historical Data Load Complete",
|
||||||
|
alert_message=(
|
||||||
|
f"Successfully loaded {save_stats['saved']} historical Bitcoin price records.\n"
|
||||||
|
f"Date range: {days} days\n"
|
||||||
|
f"Errors: {save_stats['errors']}\n"
|
||||||
|
f"Total in database: {save_stats['total']}"
|
||||||
|
),
|
||||||
|
severity='info',
|
||||||
|
affected_component='data_loader'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Notification sent!"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Failed to send notification: {e}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display database stats
|
||||||
|
from monitor.models import BitcoinPrice
|
||||||
|
total_records = BitcoinPrice.objects.count()
|
||||||
|
latest_record = BitcoinPrice.objects.order_by('-timestamp').first()
|
||||||
|
|
||||||
|
self.stdout.write("\n" + self.style.SUCCESS("Database Status"))
|
||||||
|
self.stdout.write("-" * 30)
|
||||||
|
self.stdout.write(f"Total Bitcoin price records: {total_records}")
|
||||||
|
|
||||||
|
if latest_record:
|
||||||
|
self.stdout.write(
|
||||||
|
f"Latest price: ${latest_record.price_usd} "
|
||||||
|
f"at {latest_record.timestamp.strftime('%Y-%m-%d %H:%M UTC')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("\n" + self.style.SUCCESS("✅ Historical data loading complete!"))
|
||||||
|
|
||||||
|
# Provide next steps
|
||||||
|
self.stdout.write("\n" + self.style.HTTP_INFO("Next Steps:"))
|
||||||
|
self.stdout.write("1. View data in admin: http://localhost:8000/admin/monitor/bitcoinprice/")
|
||||||
|
self.stdout.write("2. Run analysis: python manage.py load_historical_data --analyze")
|
||||||
|
self.stdout.write("3. View dashboard: http://localhost:8000/")
|
||||||
|
self.stdout.write("4. Test email notifications by running an analysis")
|
||||||
33
monitor/management/commands/load_sample_data.py
Normal file
33
monitor/management/commands/load_sample_data.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from monitor.models import BitcoinPrice
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Load sample Bitcoin price data'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
# Clear existing data
|
||||||
|
BitcoinPrice.objects.all().delete()
|
||||||
|
|
||||||
|
# Create sample data (last 7 days)
|
||||||
|
base_price = 45000
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for i in range(168): # 7 days * 24 hours = 168 hours
|
||||||
|
timestamp = now - timedelta(hours=i)
|
||||||
|
# Random price variation ±5%
|
||||||
|
variation = random.uniform(0.95, 1.05)
|
||||||
|
price = round(base_price * variation, 2)
|
||||||
|
|
||||||
|
BitcoinPrice.objects.create(
|
||||||
|
timestamp=timestamp,
|
||||||
|
price_usd=price,
|
||||||
|
volume=random.uniform(20000000000, 40000000000),
|
||||||
|
market_cap=random.uniform(800000000000, 900000000000),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Successfully created {BitcoinPrice.objects.count()} sample records')
|
||||||
|
)
|
||||||
28
monitor/management/commands/send_test_email.py
Normal file
28
monitor/management/commands/send_test_email.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from monitor.services.email_service import EmailService
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Send a test email to verify configuration'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--email',
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help='Email address to send test to'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
email = options['email']
|
||||||
|
email_service = EmailService()
|
||||||
|
|
||||||
|
self.stdout.write(f'Sending test email to {email}...')
|
||||||
|
|
||||||
|
success, message = email_service.send_test_email(email)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.stdout.write(self.style.SUCCESS('Test email sent successfully!'))
|
||||||
|
self.stdout.write('Check your inbox (and spam folder).')
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.ERROR(f'Failed to send test email: {message}'))
|
||||||
43
monitor/management/commands/setup_notifications.py
Normal file
43
monitor/management/commands/setup_notifications.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from monitor.models import NotificationPreference
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Setup initial notification preferences'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--emails',
|
||||||
|
nargs='+',
|
||||||
|
type=str,
|
||||||
|
default=['ali.c.zeybek@gmail.com', 'alican@alicanzeybek.xyz'],
|
||||||
|
help='Email addresses to setup notifications for'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
emails = options['emails']
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
# Check if preference already exists
|
||||||
|
pref, created = NotificationPreference.objects.get_or_create(
|
||||||
|
email_address=email,
|
||||||
|
defaults={
|
||||||
|
'receive_event_alerts': True,
|
||||||
|
'receive_system_alerts': True,
|
||||||
|
'receive_daily_digest': True,
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Created notification preference for {email}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Notification preference for {email} already exists')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Setup complete for {len(emails)} email(s)')
|
||||||
|
)
|
||||||
27
monitor/migrations/0001_initial.py
Normal file
27
monitor/migrations/0001_initial.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-09 20:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BitcoinPrice',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('price_usd', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('volume', models.DecimalField(blank=True, decimal_places=2, max_digits=30, null=True)),
|
||||||
|
('market_cap', models.DecimalField(blank=True, decimal_places=2, max_digits=30, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
37
monitor/migrations/0002_marketanalysis.py
Normal file
37
monitor/migrations/0002_marketanalysis.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-09 21:03
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('monitor', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MarketAnalysis',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('period', models.CharField(choices=[('hourly', 'Hourly'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('yearly', 'Yearly')], default='hourly', max_length=10)),
|
||||||
|
('current_price', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('average_price', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('min_price', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('max_price', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('status', models.CharField(choices=[('dip', 'Dip'), ('peak', 'Peak'), ('neutral', 'Neutral')], default='neutral', max_length=10)),
|
||||||
|
('threshold_percent', models.DecimalField(decimal_places=2, default=15.0, max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0.01')), django.core.validators.MaxValueValidator(Decimal('100'))])),
|
||||||
|
('lower_threshold', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('upper_threshold', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||||
|
('is_event', models.BooleanField(default=False)),
|
||||||
|
('event_type', models.CharField(blank=True, choices=[('dip_below', 'Dip Below Threshold'), ('rise_above', 'Rise Above Threshold'), ('neutralized', 'Neutralized')], max_length=20)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Market Analyses',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
monitor/migrations/0003_systemstatus.py
Normal file
30
monitor/migrations/0003_systemstatus.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-09 21:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('monitor', '0002_marketanalysis'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SystemStatus',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_hourly_update', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_successful_fetch', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('current_price', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)),
|
||||||
|
('last_error', models.TextField(blank=True)),
|
||||||
|
('is_stale', models.BooleanField(default=True)),
|
||||||
|
('is_healthy', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'System Statuses',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-09 22:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('monitor', '0003_systemstatus'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationPreference',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email_address', models.EmailField(max_length=254, unique=True)),
|
||||||
|
('receive_event_alerts', models.BooleanField(default=True, help_text='Receive immediate alerts for threshold events')),
|
||||||
|
('receive_system_alerts', models.BooleanField(default=True, help_text='Receive alerts for system failures and issues')),
|
||||||
|
('receive_daily_digest', models.BooleanField(default=True, help_text='Receive daily summary email at 8 AM')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailNotification',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('recipient', models.EmailField(max_length=254)),
|
||||||
|
('subject', models.CharField(max_length=255)),
|
||||||
|
('notification_type', models.CharField(choices=[('event', 'Event Alert'), ('system', 'System Alert'), ('digest', 'Daily Digest'), ('test', 'Test Email')], max_length=20)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20)),
|
||||||
|
('content_html', models.TextField(blank=True)),
|
||||||
|
('content_text', models.TextField(blank=True)),
|
||||||
|
('error_message', models.TextField(blank=True)),
|
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('retry_count', models.IntegerField(default=0)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['recipient', 'created_at'], name='monitor_ema_recipie_afeb01_idx'), models.Index(fields=['notification_type', 'status'], name='monitor_ema_notific_6e50b6_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
monitor/migrations/__init__.py
Normal file
0
monitor/migrations/__init__.py
Normal file
167
monitor/models.py
Normal file
167
monitor/models.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
class BitcoinPrice(models.Model):
|
||||||
|
"""
|
||||||
|
Stores historical Bitcoin price data.
|
||||||
|
"""
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
price_usd = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
volume = models.DecimalField(max_digits=30, decimal_places=2, null=True, blank=True)
|
||||||
|
market_cap = models.DecimalField(max_digits=30, decimal_places=2, null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Bitcoin: ${self.price_usd} at {self.timestamp.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp'] # Newest first
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MarketAnalysis(models.Model):
|
||||||
|
"""
|
||||||
|
Stores market analysis results.
|
||||||
|
"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('dip', 'Dip'),
|
||||||
|
('peak', 'Peak'),
|
||||||
|
('neutral', 'Neutral'),
|
||||||
|
]
|
||||||
|
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
period = models.CharField(max_length=10, choices=[
|
||||||
|
('hourly', 'Hourly'),
|
||||||
|
('daily', 'Daily'),
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('yearly', 'Yearly'),
|
||||||
|
], default='hourly')
|
||||||
|
|
||||||
|
# Price statistics
|
||||||
|
current_price = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
average_price = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
min_price = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
max_price = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
|
||||||
|
# Analysis results
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='neutral')
|
||||||
|
threshold_percent = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=2,
|
||||||
|
default=15.0,
|
||||||
|
validators=[MinValueValidator(Decimal('0.01')), MaxValueValidator(Decimal('100'))]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculated thresholds
|
||||||
|
lower_threshold = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
upper_threshold = models.DecimalField(max_digits=20, decimal_places=2)
|
||||||
|
|
||||||
|
# Event tracking
|
||||||
|
is_event = models.BooleanField(default=False)
|
||||||
|
event_type = models.CharField(max_length=20, blank=True, choices=[
|
||||||
|
('dip_below', 'Dip Below Threshold'),
|
||||||
|
('rise_above', 'Rise Above Threshold'),
|
||||||
|
('neutralized', 'Neutralized'),
|
||||||
|
])
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Analysis: {self.status.upper()} at ${self.current_price}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
verbose_name_plural = "Market Analyses"
|
||||||
|
|
||||||
|
class SystemStatus(models.Model):
|
||||||
|
"""
|
||||||
|
Tracks system health and status for monitoring.
|
||||||
|
"""
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
last_hourly_update = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_successful_fetch = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Current status
|
||||||
|
current_price = models.DecimalField(
|
||||||
|
max_digits=20, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error tracking
|
||||||
|
last_error = models.TextField(blank=True)
|
||||||
|
is_stale = models.BooleanField(default=True)
|
||||||
|
is_healthy = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
verbose_name_plural = "System Statuses"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = "Healthy" if self.is_healthy else "Unhealthy"
|
||||||
|
return f"System Status: {status} at {self.timestamp}"
|
||||||
|
|
||||||
|
class NotificationPreference(models.Model):
|
||||||
|
"""
|
||||||
|
Stores notification preferences for each email address.
|
||||||
|
"""
|
||||||
|
email_address = models.EmailField(unique=True)
|
||||||
|
receive_event_alerts = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Receive immediate alerts for threshold events"
|
||||||
|
)
|
||||||
|
receive_system_alerts = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Receive alerts for system failures and issues"
|
||||||
|
)
|
||||||
|
receive_daily_digest = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Receive daily summary email at 8 AM"
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.email_address} (Active: {self.is_active})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotification(models.Model):
|
||||||
|
"""
|
||||||
|
Records of all emails sent by the system.
|
||||||
|
"""
|
||||||
|
NOTIFICATION_TYPES = [
|
||||||
|
('event', 'Event Alert'),
|
||||||
|
('system', 'System Alert'),
|
||||||
|
('digest', 'Daily Digest'),
|
||||||
|
('test', 'Test Email'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('retrying', 'Retrying'),
|
||||||
|
]
|
||||||
|
|
||||||
|
recipient = models.EmailField()
|
||||||
|
subject = models.CharField(max_length=255)
|
||||||
|
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
content_html = models.TextField(blank=True)
|
||||||
|
content_text = models.TextField(blank=True)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
retry_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.notification_type} to {self.recipient} - {self.status}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['recipient', 'created_at']),
|
||||||
|
models.Index(fields=['notification_type', 'status']),
|
||||||
|
]
|
||||||
124
monitor/services/analyzer.py
Normal file
124
monitor/services/analyzer.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Avg, Min, Max
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketAnalyzer:
|
||||||
|
"""Service for analyzing Bitcoin market data."""
|
||||||
|
|
||||||
|
def __init__(self, threshold_percent=15.0):
|
||||||
|
self.threshold_percent = Decimal(str(threshold_percent))
|
||||||
|
|
||||||
|
def analyze_market(self, period='hourly'):
|
||||||
|
"""Analyze the Bitcoin market for a given period."""
|
||||||
|
try:
|
||||||
|
# Get data for the period
|
||||||
|
time_filter = self._get_time_filter(period)
|
||||||
|
prices = BitcoinPrice.objects.filter(timestamp__gte=time_filter)
|
||||||
|
|
||||||
|
if not prices.exists():
|
||||||
|
logger.warning(f"No price data available for {period} analysis")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get latest price
|
||||||
|
latest_price = prices.latest('timestamp')
|
||||||
|
|
||||||
|
# Calculate statistics
|
||||||
|
stats = prices.aggregate(
|
||||||
|
avg=Avg('price_usd'),
|
||||||
|
min=Min('price_usd'),
|
||||||
|
max=Max('price_usd'),
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_price = stats['avg'] or latest_price.price_usd
|
||||||
|
min_price = stats['min'] or latest_price.price_usd
|
||||||
|
max_price = stats['max'] or latest_price.price_usd
|
||||||
|
|
||||||
|
# Calculate thresholds
|
||||||
|
lower_threshold = avg_price * (1 - self.threshold_percent / 100)
|
||||||
|
upper_threshold = avg_price * (1 + self.threshold_percent / 100)
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
current_price = latest_price.price_usd
|
||||||
|
|
||||||
|
if current_price < lower_threshold:
|
||||||
|
status = 'dip'
|
||||||
|
is_event = True
|
||||||
|
event_type = 'dip_below'
|
||||||
|
elif current_price > upper_threshold:
|
||||||
|
status = 'peak'
|
||||||
|
is_event = True
|
||||||
|
event_type = 'rise_above'
|
||||||
|
else:
|
||||||
|
status = 'neutral'
|
||||||
|
is_event = False
|
||||||
|
event_type = ''
|
||||||
|
|
||||||
|
# Save analysis
|
||||||
|
analysis = MarketAnalysis.objects.create(
|
||||||
|
period=period,
|
||||||
|
current_price=current_price,
|
||||||
|
average_price=avg_price,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
status=status,
|
||||||
|
threshold_percent=self.threshold_percent,
|
||||||
|
lower_threshold=lower_threshold,
|
||||||
|
upper_threshold=upper_threshold,
|
||||||
|
is_event=is_event,
|
||||||
|
event_type=event_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Market analysis saved: {status} at ${current_price}")
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except BitcoinPrice.DoesNotExist:
|
||||||
|
logger.error("No Bitcoin price data found")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing market: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_time_filter(self, period):
|
||||||
|
"""Get datetime filter based on period."""
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
if period == 'hourly':
|
||||||
|
return now - timedelta(hours=1)
|
||||||
|
elif period == 'daily':
|
||||||
|
return now - timedelta(days=1)
|
||||||
|
elif period == 'weekly':
|
||||||
|
return now - timedelta(weeks=1)
|
||||||
|
elif period == 'yearly':
|
||||||
|
return now - timedelta(days=365)
|
||||||
|
else:
|
||||||
|
return now - timedelta(days=1) # Default to daily
|
||||||
|
|
||||||
|
def get_latest_analysis(self, period='hourly'):
|
||||||
|
"""Get the latest analysis for a period."""
|
||||||
|
try:
|
||||||
|
return MarketAnalysis.objects.filter(period=period).latest('timestamp')
|
||||||
|
except MarketAnalysis.DoesNotExist:
|
||||||
|
return None
|
||||||
|
def get_analysis_summary(self):
|
||||||
|
"""Get summary of all analyses."""
|
||||||
|
summary = {}
|
||||||
|
|
||||||
|
for period in ['hourly', 'daily', 'weekly', 'yearly']:
|
||||||
|
analysis = self.get_latest_analysis(period)
|
||||||
|
if analysis:
|
||||||
|
summary[period] = {
|
||||||
|
'status': analysis.status,
|
||||||
|
'current_price': float(analysis.current_price),
|
||||||
|
'average_price': float(analysis.average_price),
|
||||||
|
'threshold_percent': float(analysis.threshold_percent),
|
||||||
|
'is_event': analysis.is_event,
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
376
monitor/services/email_service.py
Normal file
376
monitor/services/email_service.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from monitor.models import NotificationPreference, EmailNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""Service for sending Bitcoin monitor notifications."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.site_domain = getattr(settings, 'SITE_DOMAIN', 'localhost:8000')
|
||||||
|
self.from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@bitcoin-monitor.com')
|
||||||
|
|
||||||
|
def get_notification_preferences(self):
|
||||||
|
"""Get all active notification preferences."""
|
||||||
|
return NotificationPreference.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
def create_email_notification_record(self, recipient, subject, notification_type):
|
||||||
|
"""Create a record of the email to be sent."""
|
||||||
|
return EmailNotification.objects.create(
|
||||||
|
recipient=recipient,
|
||||||
|
subject=subject,
|
||||||
|
notification_type=notification_type,
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_notification_status(self, email_notification, status, error_message=''):
|
||||||
|
"""Update the status of an email notification."""
|
||||||
|
email_notification.status = status
|
||||||
|
email_notification.error_message = error_message
|
||||||
|
if status == 'sent':
|
||||||
|
email_notification.sent_at = datetime.now(timezone.utc)
|
||||||
|
email_notification.save()
|
||||||
|
|
||||||
|
def render_email_template(self, template_name, context):
|
||||||
|
"""Render email template with context."""
|
||||||
|
# Add common context variables
|
||||||
|
context.update({
|
||||||
|
'dashboard_url': f'http://{self.site_domain}',
|
||||||
|
'admin_url': f'http://{self.site_domain}/admin/',
|
||||||
|
'unsubscribe_url': f'http://{self.site_domain}/api/notifications/unsubscribe/',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Render HTML and plain text versions
|
||||||
|
html_content = render_to_string(f'emails/{template_name}', context)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
return html_content, text_content
|
||||||
|
|
||||||
|
def send_event_alert(self, analysis):
|
||||||
|
"""Send event alert email."""
|
||||||
|
try:
|
||||||
|
# Get recipients who want event alerts
|
||||||
|
preferences = self.get_notification_preferences().filter(
|
||||||
|
receive_event_alerts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not preferences.exists():
|
||||||
|
logger.info("No recipients for event alerts")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate percent change
|
||||||
|
percent_change = abs(
|
||||||
|
(float(analysis.current_price) - float(analysis.average_price)) /
|
||||||
|
float(analysis.average_price) * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'alert_type': analysis.status,
|
||||||
|
'current_price': float(analysis.current_price),
|
||||||
|
'yearly_average': float(analysis.average_price),
|
||||||
|
'threshold_percent': float(analysis.threshold_percent),
|
||||||
|
'lower_threshold': float(analysis.lower_threshold),
|
||||||
|
'upper_threshold': float(analysis.upper_threshold),
|
||||||
|
'percent_change': percent_change,
|
||||||
|
'detected_at': analysis.timestamp,
|
||||||
|
'previous_status': 'neutral', # Could be tracked
|
||||||
|
'recommendation': self._get_event_recommendation(analysis),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send to each recipient
|
||||||
|
for pref in preferences:
|
||||||
|
try:
|
||||||
|
email_notification = self.create_email_notification_record(
|
||||||
|
recipient=pref.email_address,
|
||||||
|
subject=f"🚨 Bitcoin {analysis.status.upper()} Alert: "
|
||||||
|
f"${float(analysis.current_price):.2f}",
|
||||||
|
notification_type='event'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_single_email(
|
||||||
|
email_notification,
|
||||||
|
'event_alert.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send event alert to {pref.email_address}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Event alert sent to {preferences.count()} recipients")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending event alert: {e}")
|
||||||
|
|
||||||
|
def send_system_alert(self, alert_title, alert_message, severity='warning',
|
||||||
|
affected_component='system', error_code=None):
|
||||||
|
"""Send system alert email."""
|
||||||
|
try:
|
||||||
|
# Get recipients who want system alerts
|
||||||
|
preferences = self.get_notification_preferences().filter(
|
||||||
|
receive_system_alerts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not preferences.exists():
|
||||||
|
logger.info("No recipients for system alerts")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'alert_title': alert_title,
|
||||||
|
'alert_message': alert_message,
|
||||||
|
'severity': severity,
|
||||||
|
'affected_component': affected_component,
|
||||||
|
'error_code': error_code,
|
||||||
|
'occurred_at': datetime.now(timezone.utc),
|
||||||
|
'troubleshooting_steps': self._get_troubleshooting_steps(severity),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send to each recipient
|
||||||
|
for pref in preferences:
|
||||||
|
try:
|
||||||
|
email_notification = self.create_email_notification_record(
|
||||||
|
recipient=pref.email_address,
|
||||||
|
subject=f"⚠️ Bitcoin Monitor System Alert: {alert_title}",
|
||||||
|
notification_type='system'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_single_email(
|
||||||
|
email_notification,
|
||||||
|
'system_alert.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send system alert to {pref.email_address}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"System alert sent to {preferences.count()} recipients")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending system alert: {e}")
|
||||||
|
|
||||||
|
def send_daily_digest(self):
|
||||||
|
"""Send daily digest email."""
|
||||||
|
try:
|
||||||
|
# Get recipients who want daily digest
|
||||||
|
preferences = self.get_notification_preferences().filter(
|
||||||
|
receive_daily_digest=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not preferences.exists():
|
||||||
|
logger.info("No recipients for daily digest")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get data for the past 24 hours
|
||||||
|
yesterday = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis, SystemStatus
|
||||||
|
from django.db.models import Max, Min
|
||||||
|
|
||||||
|
# Get price stats
|
||||||
|
prices = BitcoinPrice.objects.filter(
|
||||||
|
timestamp__gte=yesterday
|
||||||
|
)
|
||||||
|
|
||||||
|
if prices.exists():
|
||||||
|
current_price = prices.latest('timestamp').price_usd
|
||||||
|
daily_high = prices.aggregate(Max('price_usd'))['price_usd__max']
|
||||||
|
daily_low = prices.aggregate(Min('price_usd'))['price_usd__min']
|
||||||
|
daily_change = ((float(daily_high) - float(daily_low)) / float(daily_low) * 100)
|
||||||
|
else:
|
||||||
|
current_price = daily_high = daily_low = 0
|
||||||
|
daily_change = 0
|
||||||
|
|
||||||
|
# Get market status
|
||||||
|
latest_analysis = MarketAnalysis.objects.filter(
|
||||||
|
period='hourly'
|
||||||
|
).order_by('-timestamp').first()
|
||||||
|
market_status = latest_analysis.status if latest_analysis else 'neutral'
|
||||||
|
|
||||||
|
# Get event count
|
||||||
|
events_count = MarketAnalysis.objects.filter(
|
||||||
|
is_event=True,
|
||||||
|
timestamp__gte=yesterday
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get system stats
|
||||||
|
price_fetches = prices.count()
|
||||||
|
successful_fetches = SystemStatus.objects.filter(
|
||||||
|
is_healthy=True,
|
||||||
|
timestamp__gte=yesterday
|
||||||
|
).count()
|
||||||
|
total_checks = SystemStatus.objects.filter(
|
||||||
|
timestamp__gte=yesterday
|
||||||
|
).count()
|
||||||
|
uptime_percentage = (successful_fetches / total_checks * 100) if total_checks > 0 else 0
|
||||||
|
|
||||||
|
# Get today's events
|
||||||
|
events_today = []
|
||||||
|
for event in MarketAnalysis.objects.filter(
|
||||||
|
is_event=True,
|
||||||
|
timestamp__gte=yesterday
|
||||||
|
).order_by('-timestamp')[:5]:
|
||||||
|
events_today.append({
|
||||||
|
'type': event.event_type,
|
||||||
|
'price': float(event.current_price),
|
||||||
|
'time': event.timestamp,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'date': datetime.now(timezone.utc),
|
||||||
|
'summary_period': 'Last 24 hours',
|
||||||
|
'market_status': market_status,
|
||||||
|
'current_price': float(current_price),
|
||||||
|
'daily_high': float(daily_high) if daily_high else 0,
|
||||||
|
'daily_low': float(daily_low) if daily_low else 0,
|
||||||
|
'daily_change': daily_change,
|
||||||
|
'events_count': events_count,
|
||||||
|
'price_fetches': price_fetches,
|
||||||
|
'uptime_percentage': uptime_percentage,
|
||||||
|
'events_today': events_today,
|
||||||
|
'market_insight': self._get_daily_market_insight(market_status),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send to each recipient
|
||||||
|
for pref in preferences:
|
||||||
|
try:
|
||||||
|
email_notification = self.create_email_notification_record(
|
||||||
|
recipient=pref.email_address,
|
||||||
|
subject=f"📊 Bitcoin Daily Digest - {datetime.now(timezone.utc).strftime('%b %d, %Y')}",
|
||||||
|
notification_type='digest'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_single_email(
|
||||||
|
email_notification,
|
||||||
|
'daily_digest.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send daily digest to {pref.email_address}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Daily digest sent to {preferences.count()} recipients")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending daily digest: {e}")
|
||||||
|
# Try to send system alert about the failure
|
||||||
|
self.send_system_alert(
|
||||||
|
alert_title="Daily Digest Failed",
|
||||||
|
alert_message=f"Failed to send daily digest: {str(e)}",
|
||||||
|
severity='error',
|
||||||
|
affected_component='email_service'
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_test_email(self, recipient_email):
|
||||||
|
"""Send a test email to verify configuration."""
|
||||||
|
try:
|
||||||
|
email_notification = self.create_email_notification_record(
|
||||||
|
recipient=recipient_email,
|
||||||
|
subject="✅ Bitcoin Monitor - Test Email",
|
||||||
|
notification_type='test'
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'test_message': 'This is a test email from Bitcoin Monitor.',
|
||||||
|
'timestamp': datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
self._send_single_email(
|
||||||
|
email_notification,
|
||||||
|
'system_alert.html', # Reuse system template for test
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, "Test email sent successfully"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending test email: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def _send_single_email(self, email_notification, template_name, context):
|
||||||
|
"""Send a single email and update notification record."""
|
||||||
|
try:
|
||||||
|
# Render email content
|
||||||
|
html_content, text_content = self.render_email_template(template_name, context)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=email_notification.subject,
|
||||||
|
body=text_content,
|
||||||
|
from_email=self.from_email,
|
||||||
|
to=[email_notification.recipient],
|
||||||
|
)
|
||||||
|
email.attach_alternative(html_content, "text/html")
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
email.send()
|
||||||
|
|
||||||
|
# Update notification record
|
||||||
|
email_notification.content_html = html_content
|
||||||
|
email_notification.content_text = text_content
|
||||||
|
self.update_notification_status(email_notification, 'sent')
|
||||||
|
|
||||||
|
logger.info(f"Email sent to {email_notification.recipient}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {email_notification.recipient}: {e}")
|
||||||
|
email_notification.retry_count += 1
|
||||||
|
|
||||||
|
if email_notification.retry_count < 3:
|
||||||
|
self.update_notification_status(
|
||||||
|
email_notification,
|
||||||
|
'retrying',
|
||||||
|
f"Retry {email_notification.retry_count}/3: {str(e)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_notification_status(
|
||||||
|
email_notification,
|
||||||
|
'failed',
|
||||||
|
f"Failed after 3 retries: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_event_recommendation(self, analysis):
|
||||||
|
"""Get recommendation based on event type."""
|
||||||
|
if analysis.status == 'dip':
|
||||||
|
return "Price is significantly below yearly average. Consider buying opportunity."
|
||||||
|
elif analysis.status == 'peak':
|
||||||
|
return "Price is significantly above yearly average. Consider taking profits."
|
||||||
|
else:
|
||||||
|
return "Price has returned to normal range. Monitor for further changes."
|
||||||
|
|
||||||
|
def _get_troubleshooting_steps(self, severity):
|
||||||
|
"""Get troubleshooting steps based on severity."""
|
||||||
|
steps = [
|
||||||
|
"Check the admin panel for detailed error logs",
|
||||||
|
"Verify internet connection and API access",
|
||||||
|
"Check Redis and Celery worker status",
|
||||||
|
]
|
||||||
|
|
||||||
|
if severity == 'critical':
|
||||||
|
steps.append("Restart the monitoring services if necessary")
|
||||||
|
steps.append("Check database connection and disk space")
|
||||||
|
|
||||||
|
return steps
|
||||||
|
|
||||||
|
def _get_daily_market_insight(self, market_status):
|
||||||
|
"""Get daily market insight based on status."""
|
||||||
|
insights = {
|
||||||
|
'dip': "Market shows buying opportunity with prices below yearly average. "
|
||||||
|
"Monitor for further dips before entering.",
|
||||||
|
'peak': "Market at elevated levels. Consider profit-taking strategies "
|
||||||
|
"and set stop-loss orders.",
|
||||||
|
'neutral': "Market trading within normal range. Good time to review "
|
||||||
|
"portfolio and set alerts for future movements.",
|
||||||
|
}
|
||||||
|
return insights.get(market_status, "Monitor market for emerging trends.")
|
||||||
301
monitor/services/historical_data.py
Normal file
301
monitor/services/historical_data.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import requests
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone as django_timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricalDataFetcher:
|
||||||
|
"""Fetches historical Bitcoin price data."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = "https://api.coingecko.com/api/v3"
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'BitcoinMonitor/1.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
def fetch_historical_data(self, days: int = 365) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Fetch historical Bitcoin data for specified number of days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days of historical data to fetch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of price data dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching {days} days of historical Bitcoin data...")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/coins/bitcoin/market_chart"
|
||||||
|
params = {
|
||||||
|
'vs_currency': 'usd',
|
||||||
|
'days': days,
|
||||||
|
'interval': 'daily',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(url, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
historical_data = []
|
||||||
|
|
||||||
|
# Process prices
|
||||||
|
for price_point in data.get('prices', []):
|
||||||
|
timestamp = datetime.fromtimestamp(price_point[0] / 1000, timezone.utc)
|
||||||
|
price = price_point[1]
|
||||||
|
|
||||||
|
historical_data.append({
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'price_usd': price,
|
||||||
|
'volume': None,
|
||||||
|
'market_cap': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add volume data if available
|
||||||
|
volumes = data.get('total_volumes', [])
|
||||||
|
for i, (timestamp_ms, volume) in enumerate(volumes):
|
||||||
|
if i < len(historical_data):
|
||||||
|
historical_data[i]['volume'] = volume
|
||||||
|
|
||||||
|
# Add market cap data if available
|
||||||
|
market_caps = data.get('market_caps', [])
|
||||||
|
for i, (timestamp_ms, market_cap) in enumerate(market_caps):
|
||||||
|
if i < len(historical_data):
|
||||||
|
historical_data[i]['market_cap'] = market_cap
|
||||||
|
|
||||||
|
logger.info(f"Fetched {len(historical_data)} historical price points")
|
||||||
|
return historical_data
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error fetching historical data: {e}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching historical data: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_historical_data_range(self, start_date: datetime, end_date: datetime) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Fetch historical data for a specific date range.
|
||||||
|
|
||||||
|
Note: CoinGecko API doesn't support arbitrary date ranges directly,
|
||||||
|
so we fetch maximum days and filter.
|
||||||
|
"""
|
||||||
|
# Calculate days between dates
|
||||||
|
days_difference = (end_date - start_date).days
|
||||||
|
|
||||||
|
# Fetch more data than needed to ensure we have the range
|
||||||
|
all_data = self.fetch_historical_data(days=days_difference + 100)
|
||||||
|
|
||||||
|
# Filter to date range
|
||||||
|
filtered_data = [
|
||||||
|
point for point in all_data
|
||||||
|
if start_date <= point['timestamp'] <= end_date
|
||||||
|
]
|
||||||
|
|
||||||
|
return filtered_data
|
||||||
|
|
||||||
|
def save_historical_data(self, historical_data: List[Dict], clear_existing: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Save historical data to database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
historical_data: List of price data dictionaries
|
||||||
|
clear_existing: Whether to clear existing data before saving
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with statistics about the operation
|
||||||
|
"""
|
||||||
|
if not historical_data:
|
||||||
|
logger.warning("No historical data to save")
|
||||||
|
return {'saved': 0, 'skipped': 0, 'errors': 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
if clear_existing:
|
||||||
|
deleted_count, _ = BitcoinPrice.objects.all().delete()
|
||||||
|
logger.info(f"Cleared {deleted_count} existing price records")
|
||||||
|
|
||||||
|
saved_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for data_point in historical_data:
|
||||||
|
try:
|
||||||
|
# Check if price already exists for this timestamp
|
||||||
|
exists = BitcoinPrice.objects.filter(
|
||||||
|
timestamp=data_point['timestamp']
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create BitcoinPrice object
|
||||||
|
BitcoinPrice.objects.create(
|
||||||
|
timestamp=data_point['timestamp'],
|
||||||
|
price_usd=Decimal(str(data_point['price_usd'])),
|
||||||
|
volume=Decimal(str(data_point['volume'])) if data_point.get('volume') else None,
|
||||||
|
market_cap=Decimal(str(data_point['market_cap'])) if data_point.get('market_cap') else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_count += 1
|
||||||
|
|
||||||
|
# Log progress every 50 records
|
||||||
|
if saved_count % 50 == 0:
|
||||||
|
logger.info(f"Saved {saved_count} historical records...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_count += 1
|
||||||
|
logger.error(f"Error saving data point {data_point.get('timestamp')}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Historical data saved: {saved_count} new, {skipped_count} existing, {error_count} errors")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'saved': saved_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'errors': error_count,
|
||||||
|
'total': len(historical_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction error saving historical data: {e}")
|
||||||
|
return {'saved': 0, 'skipped': 0, 'errors': len(historical_data), 'total': len(historical_data)}
|
||||||
|
|
||||||
|
def generate_test_data(self, days: int = 30, base_price: float = 45000) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Generate synthetic test data for development.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days of test data
|
||||||
|
base_price: Base price for the data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of synthetic price data
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
logger.info(f"Generating {days} days of synthetic test data...")
|
||||||
|
|
||||||
|
test_data = []
|
||||||
|
now = django_timezone.now()
|
||||||
|
|
||||||
|
for i in range(days * 24): # Generate hourly data
|
||||||
|
timestamp = now - timedelta(hours=i)
|
||||||
|
|
||||||
|
# Create realistic price fluctuations (±5%)
|
||||||
|
variation = random.uniform(0.95, 1.05)
|
||||||
|
price = base_price * variation
|
||||||
|
|
||||||
|
# Generate volume and market cap with some randomness
|
||||||
|
volume = random.uniform(20000000000, 40000000000)
|
||||||
|
market_cap = random.uniform(800000000000, 900000000000)
|
||||||
|
|
||||||
|
test_data.append({
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'price_usd': round(price, 2),
|
||||||
|
'volume': round(volume, 2),
|
||||||
|
'market_cap': round(market_cap, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Reverse to have chronological order
|
||||||
|
test_data.reverse()
|
||||||
|
|
||||||
|
logger.info(f"Generated {len(test_data)} synthetic data points")
|
||||||
|
return test_data
|
||||||
|
|
||||||
|
def analyze_historical_data_quality(self, historical_data: List[Dict]) -> Dict:
|
||||||
|
"""
|
||||||
|
Analyze the quality of historical data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
historical_data: List of price data dictionaries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with quality metrics
|
||||||
|
"""
|
||||||
|
if not historical_data:
|
||||||
|
return {'error': 'No data to analyze'}
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
sorted_data = sorted(historical_data, key=lambda x: x['timestamp'])
|
||||||
|
|
||||||
|
timestamps = [d['timestamp'] for d in sorted_data]
|
||||||
|
prices = [d['price_usd'] for d in sorted_data]
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
min_price = min(prices)
|
||||||
|
max_price = max(prices)
|
||||||
|
avg_price = sum(prices) / len(prices)
|
||||||
|
|
||||||
|
# Check for gaps in timestamps
|
||||||
|
time_gaps = []
|
||||||
|
for i in range(1, len(timestamps)):
|
||||||
|
gap = (timestamps[i] - timestamps[i-1]).total_seconds() / 3600 # hours
|
||||||
|
if gap > 24: # More than 1 day gap
|
||||||
|
time_gaps.append({
|
||||||
|
'from': timestamps[i-1],
|
||||||
|
'to': timestamps[i],
|
||||||
|
'gap_hours': gap,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for missing values
|
||||||
|
missing_prices = sum(1 for d in sorted_data if d['price_usd'] is None)
|
||||||
|
missing_volumes = sum(1 for d in sorted_data if d.get('volume') is None)
|
||||||
|
missing_market_caps = sum(1 for d in sorted_data if d.get('market_cap') is None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_points': len(historical_data),
|
||||||
|
'date_range': {
|
||||||
|
'start': timestamps[0],
|
||||||
|
'end': timestamps[-1],
|
||||||
|
'days': (timestamps[-1] - timestamps[0]).days,
|
||||||
|
},
|
||||||
|
'price_stats': {
|
||||||
|
'min': min_price,
|
||||||
|
'max': max_price,
|
||||||
|
'average': avg_price,
|
||||||
|
'range_percent': ((max_price - min_price) / min_price * 100),
|
||||||
|
},
|
||||||
|
'data_quality': {
|
||||||
|
'missing_prices': missing_prices,
|
||||||
|
'missing_volumes': missing_volumes,
|
||||||
|
'missing_market_caps': missing_market_caps,
|
||||||
|
'time_gaps': len(time_gaps),
|
||||||
|
'time_gaps_details': time_gaps[:5], # First 5 gaps
|
||||||
|
},
|
||||||
|
'suggestions': self._generate_data_quality_suggestions({
|
||||||
|
'missing_prices': missing_prices,
|
||||||
|
'time_gaps': len(time_gaps),
|
||||||
|
'total_points': len(historical_data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_data_quality_suggestions(self, metrics: Dict) -> List[str]:
|
||||||
|
"""Generate suggestions based on data quality metrics."""
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if metrics['missing_prices'] > 0:
|
||||||
|
suggestions.append(f"Found {metrics['missing_prices']} missing prices. Consider filling gaps.")
|
||||||
|
|
||||||
|
if metrics['time_gaps'] > 0:
|
||||||
|
suggestions.append(f"Found {metrics['time_gaps']} time gaps. Data may not be continuous.")
|
||||||
|
|
||||||
|
if metrics['total_points'] < 30:
|
||||||
|
suggestions.append("Less than 30 data points. Consider fetching more data.")
|
||||||
|
|
||||||
|
if not suggestions:
|
||||||
|
suggestions.append("Data quality looks good!")
|
||||||
|
|
||||||
|
return suggestions
|
||||||
173
monitor/tasks.py
Normal file
173
monitor/tasks.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
from monitor.services.email_service import EmailService
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_event_notification_task(analysis_id):
|
||||||
|
"""Send email notification for a market event."""
|
||||||
|
try:
|
||||||
|
from monitor.models import MarketAnalysis
|
||||||
|
|
||||||
|
analysis = MarketAnalysis.objects.get(id=analysis_id)
|
||||||
|
|
||||||
|
if analysis.is_event:
|
||||||
|
email_service = EmailService()
|
||||||
|
email_service.send_event_alert(analysis)
|
||||||
|
|
||||||
|
logger.info(f"Event notification sent for analysis {analysis_id}")
|
||||||
|
return {'success': True, 'analysis_id': analysis_id}
|
||||||
|
else:
|
||||||
|
logger.info(f"Analysis {analysis_id} is not an event, skipping notification")
|
||||||
|
return {'success': False, 'reason': 'Not an event'}
|
||||||
|
|
||||||
|
except MarketAnalysis.DoesNotExist:
|
||||||
|
logger.error(f"Analysis {analysis_id} not found")
|
||||||
|
return {'success': False, 'error': 'Analysis not found'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending event notification: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_daily_digest_task():
|
||||||
|
"""Send daily digest email at 8 AM."""
|
||||||
|
try:
|
||||||
|
email_service = EmailService()
|
||||||
|
email_service.send_daily_digest()
|
||||||
|
|
||||||
|
logger.info("Daily digest email sent")
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending daily digest: {e}")
|
||||||
|
# Try to send system alert about the failure
|
||||||
|
send_system_notification_task.delay(
|
||||||
|
alert_title="Daily Digest Failed",
|
||||||
|
alert_message=f"Failed to send daily digest: {str(e)}",
|
||||||
|
severity='error'
|
||||||
|
)
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_system_notification_task(alert_title, alert_message, severity='warning',
|
||||||
|
affected_component='system'):
|
||||||
|
"""Send system notification email."""
|
||||||
|
try:
|
||||||
|
email_service = EmailService()
|
||||||
|
email_service.send_system_alert(
|
||||||
|
alert_title=alert_title,
|
||||||
|
alert_message=alert_message,
|
||||||
|
severity=severity,
|
||||||
|
affected_component=affected_component
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"System notification sent: {alert_title}")
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending system notification: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# Update the fetch_bitcoin_price_task to send system alerts on failure
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def fetch_bitcoin_price_task(self):
|
||||||
|
"""Fetch current Bitcoin price and save to database."""
|
||||||
|
logger.info("Starting Bitcoin price fetch task...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetcher = CoinGeckoFetcher()
|
||||||
|
|
||||||
|
# Fetch current price
|
||||||
|
price_data = fetcher.fetch_current_price()
|
||||||
|
|
||||||
|
if not price_data:
|
||||||
|
logger.error("Failed to fetch price data")
|
||||||
|
|
||||||
|
# Send system alert
|
||||||
|
send_system_notification_task.delay(
|
||||||
|
alert_title="Bitcoin Price Fetch Failed",
|
||||||
|
alert_message="Failed to fetch Bitcoin price from CoinGecko API. Check API status and internet connection.",
|
||||||
|
severity='warning',
|
||||||
|
affected_component='data_fetcher'
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("No price data received")
|
||||||
|
|
||||||
|
# ... rest of existing code ...
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in fetch_bitcoin_price_task: {e}")
|
||||||
|
|
||||||
|
# Update system status with error
|
||||||
|
SystemStatus.objects.create(
|
||||||
|
last_error=str(e),
|
||||||
|
is_stale=True,
|
||||||
|
is_healthy=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send system alert
|
||||||
|
send_system_notification_task.delay(
|
||||||
|
alert_title="Bitcoin Price Task Error",
|
||||||
|
alert_message=f"Error in price fetch task: {str(e)}",
|
||||||
|
severity='error',
|
||||||
|
affected_component='celery_task'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retry the task
|
||||||
|
self.retry(exc=e, countdown=60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Update run_hourly_analysis_task to send event notifications
|
||||||
|
@shared_task
|
||||||
|
def run_hourly_analysis_task():
|
||||||
|
"""Run hourly market analysis."""
|
||||||
|
logger.info("Starting hourly analysis task...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Run hourly analysis
|
||||||
|
analysis = analyzer.analyze_market('hourly')
|
||||||
|
|
||||||
|
if analysis:
|
||||||
|
logger.info(f"Hourly analysis completed: {analysis.status} at ${analysis.current_price}")
|
||||||
|
|
||||||
|
# Check if this is an event and send notification
|
||||||
|
if analysis.is_event:
|
||||||
|
logger.warning(
|
||||||
|
f"Market event detected: {analysis.event_type} "
|
||||||
|
f"at ${analysis.current_price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send email notification
|
||||||
|
send_event_notification_task.delay(analysis.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'analysis_id': analysis.id,
|
||||||
|
'status': analysis.status,
|
||||||
|
'price': float(analysis.current_price),
|
||||||
|
'is_event': analysis.is_event,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning("Hourly analysis returned no results")
|
||||||
|
return {'success': False, 'error': 'No analysis results'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in run_hourly_analysis_task: {e}")
|
||||||
|
|
||||||
|
# Send system alert
|
||||||
|
send_system_notification_task.delay(
|
||||||
|
alert_title="Hourly Analysis Failed",
|
||||||
|
alert_message=f"Failed to run hourly analysis: {str(e)}",
|
||||||
|
severity='error',
|
||||||
|
affected_component='market_analyzer'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
199
monitor/templates/emails/base.html
Normal file
199
monitor/templates/emails/base.html
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Bitcoin Monitor{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
/* Gruvbox color scheme matching dashboard */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #282828;
|
||||||
|
--bg-secondary: #3c3836;
|
||||||
|
--bg-tertiary: #504945;
|
||||||
|
--text-primary: #ebdbb2;
|
||||||
|
--text-secondary: #d5c4a1;
|
||||||
|
--accent-red: #cc241d;
|
||||||
|
--accent-yellow: #d79921;
|
||||||
|
--accent-blue: #458588;
|
||||||
|
--accent-green: #98971a;
|
||||||
|
--accent-purple: #b16286;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header .subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-dip { background: #f8d7da; color: #721c24; border-left: 5px solid var(--accent-red); }
|
||||||
|
.alert-peak { background: #fff3cd; color: #856404; border-left: 5px solid var(--accent-yellow); }
|
||||||
|
.alert-system { background: #cce5ff; color: #004085; border-left: 5px solid var(--accent-blue); }
|
||||||
|
.alert-digest { background: #d4edda; color: #155724; border-left: 5px solid var(--accent-green); }
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-purple);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin: 25px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--accent-blue);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation {
|
||||||
|
background: #e8f4fd;
|
||||||
|
border-left: 4px solid var(--accent-blue);
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--accent-purple);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsubscribe-link {
|
||||||
|
color: #6c757d !important;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 10px; }
|
||||||
|
.email-body { padding: 15px; }
|
||||||
|
.stats-grid { grid-template-columns: 1fr; }
|
||||||
|
.metric-value { font-size: 24px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>₿ Bitcoin Monitor</h1>
|
||||||
|
<div class="subtitle">Real-time Bitcoin Price Monitoring</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>This email was sent by Bitcoin Monitor System.</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ dashboard_url }}">View Dashboard</a> |
|
||||||
|
<a href="{{ admin_url }}">Admin Panel</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ unsubscribe_url }}" class="unsubscribe-link">
|
||||||
|
Unsubscribe or manage preferences
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 15px; color: #999; font-size: 11px;">
|
||||||
|
Bitcoin Monitor © {% now "Y" %} • Automated notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
monitor/templates/emails/daily_digest.html
Normal file
100
monitor/templates/emails/daily_digest.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Daily Digest - Bitcoin Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="alert-banner alert-digest">
|
||||||
|
📊 DAILY DIGEST: {{ date|date:"F d, Y" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<h2 style="color: #343a40; margin-bottom: 5px;">24-Hour Summary</h2>
|
||||||
|
<p style="color: #6c757d; margin-top: 0;">{{ summary_period }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Market Status</div>
|
||||||
|
<div class="metric-value" style="color:
|
||||||
|
{% if market_status == 'dip' %}#dc3545
|
||||||
|
{% elif market_status == 'peak' %}#ffc107
|
||||||
|
{% else %}#28a745{% endif %};">
|
||||||
|
{{ market_status|upper }}
|
||||||
|
</div>
|
||||||
|
<div style="color: #6c757d; font-size: 14px;">
|
||||||
|
{% if market_status == 'dip' %}Price below yearly average
|
||||||
|
{% elif market_status == 'peak' %}Price above yearly average
|
||||||
|
{% else %}Price within normal range{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ current_price|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">Current Price</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ daily_high|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">24h High</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ daily_low|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">24h Low</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ daily_change|floatformat:1 }}%</div>
|
||||||
|
<div class="stat-label">24h Change</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0;">
|
||||||
|
<h3 style="color: #343a40; margin-bottom: 15px;">📈 Market Activity</h3>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 15px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #6c757d;">Events Today:</span>
|
||||||
|
<span style="font-weight: bold;">{{ events_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #6c757d;">Price Fetches:</span>
|
||||||
|
<span style="font-weight: bold;">{{ price_fetches }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="color: #6c757d;">System Uptime:</span>
|
||||||
|
<span style="font-weight: bold;">{{ uptime_percentage|floatformat:1 }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if events_today %}
|
||||||
|
<div style="background: #e8f4fd; padding: 15px; border-radius: 6px; margin-top: 15px;">
|
||||||
|
<h4 style="margin-top: 0; color: #004085;">⚠️ Events Today</h4>
|
||||||
|
<ul style="margin-bottom: 0; padding-left: 20px;">
|
||||||
|
{% for event in events_today %}
|
||||||
|
<li>
|
||||||
|
<strong>{{ event.type|title }}</strong> at
|
||||||
|
${{ event.price|floatformat:2 }} ({{ event.time|time:"H:i" }})
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if market_insight %}
|
||||||
|
<div class="recommendation">
|
||||||
|
<h4 style="margin-top: 0; color: #004085;">📋 Market Insight</h4>
|
||||||
|
<p style="margin-bottom: 0;">{{ market_insight }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px; padding: 15px; background: #f8f9fa; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="margin: 0; color: #6c757d;">
|
||||||
|
Next digest: Tomorrow at 08:00 UTC
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ dashboard_url }}" class="action-button">
|
||||||
|
View Full Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
83
monitor/templates/emails/event_alert.html
Normal file
83
monitor/templates/emails/event_alert.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ alert_type }} Alert - Bitcoin Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="alert-banner alert-{{ alert_type }}">
|
||||||
|
{% if alert_type == 'dip' %}
|
||||||
|
🚨 DIP DETECTED: Price is {{ percent_change|floatformat:1 }}% below average
|
||||||
|
{% elif alert_type == 'peak' %}
|
||||||
|
⚡ PEAK DETECTED: Price is {{ percent_change|floatformat:1 }}% above average
|
||||||
|
{% else %}
|
||||||
|
ℹ️ MARKET EVENT DETECTED
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Current Bitcoin Price</div>
|
||||||
|
<div class="metric-value">${{ current_price|floatformat:2 }}</div>
|
||||||
|
<div style="color: #6c757d; font-size: 14px;">
|
||||||
|
{% if alert_type == 'dip' %}📉 Below threshold{% else %}📈 Above threshold{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ threshold_percent }}%</div>
|
||||||
|
<div class="stat-label">Threshold</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ yearly_average|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">Yearly Average</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ lower_threshold|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">Lower Bound</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${{ upper_threshold|floatformat:2 }}</div>
|
||||||
|
<div class="stat-label">Upper Bound</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||||
|
<h3 style="margin-top: 0; color: #343a40;">Event Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; color: #6c757d;">Event Type:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; font-weight: bold; text-align: right;">
|
||||||
|
{{ alert_type|upper }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; color: #6c757d;">Detected At:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; text-align: right;">
|
||||||
|
{{ detected_at|date:"M d, Y H:i" }} UTC
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; color: #6c757d;">Price Deviation:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #dee2e6; text-align: right; font-weight: bold;">
|
||||||
|
{{ percent_change|floatformat:1 }}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #6c757d;">Previous Status:</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right;">{{ previous_status|default:"N/A"|title }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if recommendation %}
|
||||||
|
<div class="recommendation">
|
||||||
|
<h4 style="margin-top: 0; color: #004085;">💡 Recommendation</h4>
|
||||||
|
<p style="margin-bottom: 0;">{{ recommendation }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ dashboard_url }}" class="action-button">
|
||||||
|
View Live Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
52
monitor/templates/emails/system_alert.html
Normal file
52
monitor/templates/emails/system_alert.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}System Alert - Bitcoin Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="alert-banner alert-system">
|
||||||
|
⚠️ SYSTEM ALERT: {{ alert_title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #343a40; margin-top: 0;">Issue Details</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #dc3545;">
|
||||||
|
<p style="margin: 0; white-space: pre-wrap; font-family: monospace; font-size: 13px;">
|
||||||
|
{{ alert_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ affected_component }}</div>
|
||||||
|
<div class="stat-label">Affected Component</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ severity|upper }}</div>
|
||||||
|
<div class="stat-label">Severity</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ occurred_at|date:"H:i" }}</div>
|
||||||
|
<div class="stat-label">Time (UTC)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ error_code|default:"N/A" }}</div>
|
||||||
|
<div class="stat-label">Error Code</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0; padding: 20px; background: #fff3cd; border-radius: 8px; border-left: 4px solid #ffc107;">
|
||||||
|
<h4 style="margin-top: 0; color: #856404;">🛠️ Troubleshooting Steps</h4>
|
||||||
|
<ul style="margin-bottom: 0; padding-left: 20px;">
|
||||||
|
{% for step in troubleshooting_steps %}
|
||||||
|
<li>{{ step }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ admin_url }}" class="action-button" style="background: #6c757d;">
|
||||||
|
Go to Admin Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
108
monitor/templates/monitor/analysis_result.html
Normal file
108
monitor/templates/monitor/analysis_result.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Analysis Results</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
.analysis-card {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 15px 0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.status-dip { border-left: 5px solid #dc3545; }
|
||||||
|
.status-peak { border-left: 5px solid #ffc107; }
|
||||||
|
.status-neutral { border-left: 5px solid #28a745; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>✓ Analysis Complete</h1>
|
||||||
|
<p>Market analysis has been successfully executed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="success">
|
||||||
|
<h3>{{ message }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Analysis Results</h2>
|
||||||
|
|
||||||
|
{% for analysis in analyses %}
|
||||||
|
<div class="analysis-card status-{{ analysis.status }}">
|
||||||
|
<h3>{{ analysis.period|title }} Analysis</h3>
|
||||||
|
<p><strong>Status:</strong>
|
||||||
|
<span style="font-weight: bold;
|
||||||
|
{% if analysis.status == 'dip' %}color: #dc3545;
|
||||||
|
{% elif analysis.status == 'peak' %}color: #ffc107;
|
||||||
|
{% else %}color: #28a745;{% endif %}">
|
||||||
|
{{ analysis.status|upper }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>Current Price:</strong> ${{ analysis.current_price }}</p>
|
||||||
|
<p><strong>Average Price:</strong> ${{ analysis.average_price }}</p>
|
||||||
|
<p><strong>Threshold:</strong> {{ analysis.threshold_percent }}%</p>
|
||||||
|
<p><strong>Range:</strong> ${{ analysis.lower_threshold }} - ${{ analysis.upper_threshold }}</p>
|
||||||
|
{% if analysis.is_event %}
|
||||||
|
<p><strong>⚠️ Event Detected:</strong> {{ analysis.event_type|title }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><small>Analyzed at: {{ analysis.timestamp }}</small></p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<a href="{% url 'view_analysis' %}" class="btn">View All Analyses</a>
|
||||||
|
<a href="/" class="btn btn-secondary">Back to Dashboard</a>
|
||||||
|
<a href="/admin/monitor/marketanalysis/" class="btn btn-secondary">Admin Panel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
294
monitor/templates/monitor/bitcoin_data.html
Normal file
294
monitor/templates/monitor/bitcoin_data.html
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Add this script to use API -->
|
||||||
|
<script>
|
||||||
|
// Fetch status from API
|
||||||
|
function fetchApiStatus() {
|
||||||
|
fetch('/api/status/')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update status display
|
||||||
|
document.getElementById('current-price').textContent =
|
||||||
|
`$${data.current_price.toFixed(2)}`;
|
||||||
|
document.getElementById('current-status').textContent =
|
||||||
|
data.current_status.toUpperCase();
|
||||||
|
document.getElementById('current-status').className =
|
||||||
|
`status-${data.current_status}`;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
document.getElementById('yearly-avg').textContent =
|
||||||
|
`$${data.yearly_average.toFixed(2)}`;
|
||||||
|
document.getElementById('yearly-min').textContent =
|
||||||
|
`$${data.yearly_min.toFixed(2)}`;
|
||||||
|
document.getElementById('yearly-max').textContent =
|
||||||
|
`$${data.yearly_max.toFixed(2)}`;
|
||||||
|
|
||||||
|
// Show/hide stale warnings
|
||||||
|
const staleWarning = document.getElementById('stale-warning');
|
||||||
|
if (data.stale_yearly || data.stale_hourly) {
|
||||||
|
staleWarning.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
staleWarning.style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching status:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events from API
|
||||||
|
function fetchApiEvents() {
|
||||||
|
fetch('/api/events/?limit=5')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const eventsList = document.getElementById('events-list');
|
||||||
|
eventsList.innerHTML = '';
|
||||||
|
|
||||||
|
data.forEach(event => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = `
|
||||||
|
<strong>${event.event_type.replace('_', ' ').toUpperCase()}</strong>
|
||||||
|
at $${event.current_price.toFixed(2)}
|
||||||
|
(${new Date(event.timestamp).toLocaleTimeString()})
|
||||||
|
`;
|
||||||
|
eventsList.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh API data every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
fetchApiStatus();
|
||||||
|
fetchApiEvents();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchApiStatus();
|
||||||
|
fetchApiEvents();
|
||||||
|
</script>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Bitcoin Price Monitor</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #f7931a 0%, #8b4513 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.stats-card {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
{% if analysis %}
|
||||||
|
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #007bff;">
|
||||||
|
<h3>Latest Analysis</h3>
|
||||||
|
<p><strong>Status:</strong>
|
||||||
|
<span style="font-weight: bold;
|
||||||
|
{% if analysis.status == 'dip' %}color: #dc3545;
|
||||||
|
{% elif analysis.status == 'peak' %}color: #ffc107;
|
||||||
|
{% else %}color: #28a745;{% endif %}">
|
||||||
|
{{ analysis.status|upper }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>Average (Hourly):</strong> ${{ analysis.average_price|floatformat:2 }}</p>
|
||||||
|
<p><strong>Threshold:</strong> ±{{ analysis.threshold_percent }}%</p>
|
||||||
|
{% if analysis.is_event %}
|
||||||
|
<p style="color: #dc3545; font-weight: bold;">
|
||||||
|
⚠️ {{ analysis.event_type|title }} event detected!
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'view_analysis' %}" style="color: #007bff; text-decoration: none;">View detailed analysis →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
.price-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.price-table th {
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.price-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.price-table tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.btn-fetch {
|
||||||
|
background: #007bff;
|
||||||
|
}
|
||||||
|
.btn-fetch:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
.btn-admin {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.btn-admin:hover {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: #28a745;
|
||||||
|
padding: 10px;
|
||||||
|
background: #d4edda;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8d7da;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>₿ Bitcoin Price Monitor</h1>
|
||||||
|
<p>Real-time Bitcoin price tracking and historical data</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-card">
|
||||||
|
<h2>Current Status</h2>
|
||||||
|
<p><strong>Latest Price:</strong> ${{ stats.latest_price }}</p>
|
||||||
|
<p><strong>Last Updated:</strong> {{ stats.latest_time }}</p>
|
||||||
|
<p><strong>Total Records:</strong> {{ stats.total_records }}</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-fetch" onclick="fetchPrice()">
|
||||||
|
Fetch Current Price
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick="location.reload()">
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
<a href="/admin/monitor/bitcoinprice/" class="btn btn-admin">
|
||||||
|
Admin Panel
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'run_analysis' %}" class="btn" style="background: #28a745;">
|
||||||
|
Run Analysis
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'view_analysis' %}" class="btn" style="background: #17a2b8;">
|
||||||
|
View Analysis
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Recent Prices (Last 10)</h2>
|
||||||
|
{% if prices %}
|
||||||
|
<table class="price-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Price (USD)</th>
|
||||||
|
<th>Volume</th>
|
||||||
|
<th>Market Cap</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for price in prices %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ price.timestamp }}</td>
|
||||||
|
<td>${{ price.price_usd }}</td>
|
||||||
|
<td>{% if price.volume %}${{ price.volume }}{% else %}-{% endif %}</td>
|
||||||
|
<td>{% if price.market_cap %}${{ price.market_cap }}{% else %}-{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No price data available yet. Click "Fetch Current Price" to get started!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="api-status-display" style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||||
|
<h3>API Status</h3>
|
||||||
|
<p><strong>Current Price:</strong> <span id="current-price">Loading...</span></p>
|
||||||
|
<p><strong>Status:</strong> <span id="current-status" class="status-neutral">Loading...</span></p>
|
||||||
|
<p><strong>Yearly Average:</strong> <span id="yearly-avg">Loading...</span></p>
|
||||||
|
<div id="stale-warning" style="display: none; color: #dc3545; padding: 10px; background: #f8d7da; border-radius: 5px;">
|
||||||
|
⚠️ Data may be stale. Last fetch was more than 1 hour ago.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0;">
|
||||||
|
<h3>Recent Events</h3>
|
||||||
|
<ul id="events-list">
|
||||||
|
<li>Loading events...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-dip { color: #dc3545; font-weight: bold; }
|
||||||
|
.status-peak { color: #ffc107; font-weight: bold; }
|
||||||
|
.status-neutral { color: #28a745; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function fetchPrice() {
|
||||||
|
const messageDiv = document.getElementById('message');
|
||||||
|
messageDiv.innerHTML = '<p>Fetching current price...</p>';
|
||||||
|
messageDiv.className = '';
|
||||||
|
|
||||||
|
fetch('/fetch-price/')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
messageDiv.innerHTML = `<p class="success">${data.message}</p>`;
|
||||||
|
// Reload the page after 2 seconds to show new data
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
messageDiv.innerHTML = `<p class="error">${data.message}</p>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
messageDiv.innerHTML = `<p class="error">Error: ${error}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
const currentTime = new Date().toLocaleTimeString();
|
||||||
|
console.log(`Auto-refreshing at ${currentTime}`);
|
||||||
|
fetchPrice();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
monitor/templates/monitor/view_analysis.html
Normal file
195
monitor/templates/monitor/view_analysis.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Market Analysis</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.summary-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.status-dip { background: #dc3545; color: white; }
|
||||||
|
.status-peak { background: #ffc107; color: #212529; }
|
||||||
|
.status-neutral { background: #28a745; color: white; }
|
||||||
|
|
||||||
|
.analysis-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.analysis-table th {
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.analysis-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.analysis-table tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
.btn-run {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
.btn-run:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📊 Market Analysis Dashboard</h1>
|
||||||
|
<p>Comprehensive Bitcoin market analysis across different time periods</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'run_analysis' %}" class="btn btn-run">Run New Analysis</a>
|
||||||
|
<a href="/" class="btn">Back to Dashboard</a>
|
||||||
|
<a href="/admin/monitor/marketanalysis/" class="btn btn-secondary">Admin Panel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Latest Analysis Summary</h2>
|
||||||
|
|
||||||
|
{% if summary %}
|
||||||
|
<div class="summary-grid">
|
||||||
|
{% for period, data in summary.items %}
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>{{ period|title }} Analysis
|
||||||
|
<span class="status-badge status-{{ data.status }}">
|
||||||
|
{{ data.status|upper }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p><strong>Current Price:</strong> ${{ data.current_price|floatformat:2 }}</p>
|
||||||
|
<p><strong>Average Price:</strong> ${{ data.average_price|floatformat:2 }}</p>
|
||||||
|
<p><strong>Threshold:</strong> {{ data.threshold_percent }}%</p>
|
||||||
|
{% if data.is_event %}
|
||||||
|
<p><strong>⚠️ Event Active</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data">
|
||||||
|
<h3>No analysis data available</h3>
|
||||||
|
<p>Click "Run New Analysis" to generate your first analysis report.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Recent Analyses</h2>
|
||||||
|
|
||||||
|
{% if all_analyses %}
|
||||||
|
<table class="analysis-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Period</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Current Price</th>
|
||||||
|
<th>Average Price</th>
|
||||||
|
<th>Threshold</th>
|
||||||
|
<th>Event</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for analysis in all_analyses %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ analysis.timestamp|date:"M d, H:i" }}</td>
|
||||||
|
<td>{{ analysis.period }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ analysis.status }}">
|
||||||
|
{{ analysis.status|upper }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${{ analysis.current_price|floatformat:2 }}</td>
|
||||||
|
<td>${{ analysis.average_price|floatformat:2 }}</td>
|
||||||
|
<td>{{ analysis.threshold_percent }}%</td>
|
||||||
|
<td>
|
||||||
|
{% if analysis.is_event %}
|
||||||
|
<span style="color: #dc3545;">{{ analysis.event_type|title }}</span>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data">
|
||||||
|
<p>No analysis records found. Run an analysis to see results here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
monitor/tests.py
Normal file
3
monitor/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
207
monitor/views.py
Normal file
207
monitor/views.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from .models import BitcoinPrice
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bitcoin_price(request):
|
||||||
|
"""Fetch current Bitcoin price from CoinGecko API and save it."""
|
||||||
|
try:
|
||||||
|
# Simple API call to CoinGecko
|
||||||
|
response = requests.get(
|
||||||
|
'https://api.coingecko.com/api/v3/simple/price',
|
||||||
|
params={'ids': 'bitcoin', 'vs_currencies': 'usd'}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if 'bitcoin' in data:
|
||||||
|
price = data['bitcoin']['usd']
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
BitcoinPrice.objects.create(
|
||||||
|
price_usd=price,
|
||||||
|
timestamp=datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
analyzer.analyze_market('hourly')
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'price': price,
|
||||||
|
'message': f'Price saved: ${price}'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Failed to fetch price'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Error: {str(e)}'
|
||||||
|
})
|
||||||
|
|
||||||
|
from monitor.services.analyzer import MarketAnalyzer
|
||||||
|
from monitor.models import MarketAnalysis # Add this import
|
||||||
|
|
||||||
|
|
||||||
|
# Add new view functions
|
||||||
|
def run_analysis(request):
|
||||||
|
"""Run market analysis."""
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Run analysis for different periods
|
||||||
|
analyses = []
|
||||||
|
for period in ['hourly', 'daily', 'yearly']:
|
||||||
|
analysis = analyzer.analyze_market(period)
|
||||||
|
if analysis:
|
||||||
|
analyses.append(analysis)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'analyses': analyses,
|
||||||
|
'message': f'Ran {len(analyses)} analyses successfully',
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'monitor/analysis_result.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def view_analysis(request):
|
||||||
|
"""View latest market analysis."""
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Get latest analyses
|
||||||
|
hourly = analyzer.get_latest_analysis('hourly')
|
||||||
|
daily = analyzer.get_latest_analysis('daily')
|
||||||
|
yearly = analyzer.get_latest_analysis('yearly')
|
||||||
|
|
||||||
|
# Get analysis summary
|
||||||
|
summary = analyzer.get_analysis_summary()
|
||||||
|
|
||||||
|
# Get all analyses for the table
|
||||||
|
all_analyses = MarketAnalysis.objects.all().order_by('-timestamp')[:20]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hourly_analysis': hourly,
|
||||||
|
'daily_analysis': daily,
|
||||||
|
'yearly_analysis': yearly,
|
||||||
|
'summary': summary,
|
||||||
|
'all_analyses': all_analyses,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'monitor/view_analysis.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# Update bitcoin_data view to include analysis
|
||||||
|
def bitcoin_data(request):
|
||||||
|
"""Display Bitcoin data from database."""
|
||||||
|
# Get latest 10 prices
|
||||||
|
latest_prices = BitcoinPrice.objects.all()[:10]
|
||||||
|
|
||||||
|
# Calculate basic stats if we have data
|
||||||
|
if latest_prices:
|
||||||
|
latest_price = latest_prices[0]
|
||||||
|
stats = {
|
||||||
|
'latest_price': latest_price.price_usd,
|
||||||
|
'latest_time': latest_price.timestamp,
|
||||||
|
'total_records': BitcoinPrice.objects.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get latest hourly analysis
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
hourly_analysis = analyzer.get_latest_analysis('hourly')
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
'latest_price': 'No data',
|
||||||
|
'latest_time': 'No data',
|
||||||
|
'total_records': 0,
|
||||||
|
}
|
||||||
|
hourly_analysis = None
|
||||||
|
|
||||||
|
return render(request, 'monitor/bitcoin_data.html', {
|
||||||
|
'prices': latest_prices,
|
||||||
|
'stats': stats,
|
||||||
|
'analysis': hourly_analysis, # Add analysis to context
|
||||||
|
})
|
||||||
|
# Add these imports at the top
|
||||||
|
|
||||||
|
|
||||||
|
# Add new view functions
|
||||||
|
def run_analysis(request):
|
||||||
|
"""Run market analysis."""
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Run analysis for different periods
|
||||||
|
analyses = []
|
||||||
|
for period in ['hourly', 'daily', 'yearly']:
|
||||||
|
analysis = analyzer.analyze_market(period)
|
||||||
|
if analysis:
|
||||||
|
analyses.append(analysis)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'analyses': analyses,
|
||||||
|
'message': f'Ran {len(analyses)} analyses successfully',
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'monitor/analysis_result.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def view_analysis(request):
|
||||||
|
"""View latest market analysis."""
|
||||||
|
analyzer = MarketAnalyzer(threshold_percent=15.0)
|
||||||
|
|
||||||
|
# Get latest analyses
|
||||||
|
hourly = analyzer.get_latest_analysis('hourly')
|
||||||
|
daily = analyzer.get_latest_analysis('daily')
|
||||||
|
yearly = analyzer.get_latest_analysis('yearly')
|
||||||
|
|
||||||
|
# Get analysis summary
|
||||||
|
summary = analyzer.get_analysis_summary()
|
||||||
|
|
||||||
|
# Get all analyses for the table
|
||||||
|
all_analyses = MarketAnalysis.objects.all().order_by('-timestamp')[:20]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hourly_analysis': hourly,
|
||||||
|
'daily_analysis': daily,
|
||||||
|
'yearly_analysis': yearly,
|
||||||
|
'summary': summary,
|
||||||
|
'all_analyses': all_analyses,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'monitor/view_analysis.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# Update bitcoin_data view to include analysis
|
||||||
|
def bitcoin_data(request):
|
||||||
|
"""Display Bitcoin data from database."""
|
||||||
|
# Get latest 10 prices
|
||||||
|
latest_prices = BitcoinPrice.objects.all()[:10]
|
||||||
|
|
||||||
|
# Calculate basic stats if we have data
|
||||||
|
if latest_prices:
|
||||||
|
latest_price = latest_prices[0]
|
||||||
|
stats = {
|
||||||
|
'latest_price': latest_price.price_usd,
|
||||||
|
'latest_time': latest_price.timestamp,
|
||||||
|
'total_records': BitcoinPrice.objects.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get latest hourly analysis
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
hourly_analysis = analyzer.get_latest_analysis('hourly')
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
'latest_price': 'No data',
|
||||||
|
'latest_time': 'No data',
|
||||||
|
'total_records': 0,
|
||||||
|
}
|
||||||
|
hourly_analysis = None
|
||||||
|
|
||||||
|
return render(request, 'monitor/bitcoin_data.html', {
|
||||||
|
'prices': latest_prices,
|
||||||
|
'stats': stats,
|
||||||
|
'analysis': hourly_analysis, # Add analysis to context
|
||||||
|
})
|
||||||
41
requirements.txt
Normal file
41
requirements.txt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
amqp==5.3.1
|
||||||
|
asgiref==3.11.0
|
||||||
|
billiard==4.2.4
|
||||||
|
celery==5.6.0
|
||||||
|
certifi==2025.11.12
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
click-didyoumean==0.3.1
|
||||||
|
click-plugins==1.1.1.2
|
||||||
|
click-repl==0.3.0
|
||||||
|
contourpy==1.3.3
|
||||||
|
cycler==0.12.1
|
||||||
|
Django==6.0
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
djangorestframework==3.16.1
|
||||||
|
exceptiongroup==1.3.1
|
||||||
|
fonttools==4.61.0
|
||||||
|
idna==3.11
|
||||||
|
kiwisolver==1.4.9
|
||||||
|
kombu==5.6.1
|
||||||
|
matplotlib==3.10.7
|
||||||
|
narwhals==2.13.0
|
||||||
|
numpy==2.3.5
|
||||||
|
packaging==25.0
|
||||||
|
pandas==2.3.3
|
||||||
|
pillow==12.0.0
|
||||||
|
plotly==6.5.0
|
||||||
|
prompt_toolkit==3.0.52
|
||||||
|
pyparsing==3.2.5
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
pytz==2025.2
|
||||||
|
redis==7.1.0
|
||||||
|
requests==2.32.5
|
||||||
|
six==1.17.0
|
||||||
|
sqlparse==0.5.4
|
||||||
|
tzdata==2025.2
|
||||||
|
tzlocal==5.3.1
|
||||||
|
urllib3==2.6.1
|
||||||
|
vine==5.1.0
|
||||||
|
wcwidth==0.2.14
|
||||||
108
scripts/initialize_sample_data.py
Executable file
108
scripts/initialize_sample_data.py
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Script to initialize the database with sample data for development.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Add project to path
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Setup Django
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from monitor.models import BitcoinPrice, MarketAnalysis, NotificationPreference
|
||||||
|
from monitor.services.historical_data import HistoricalDataFetcher
|
||||||
|
from monitor.services.analyzer import MarketAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_sample_data():
|
||||||
|
"""Initialize database with sample data."""
|
||||||
|
print("🚀 Initializing Bitcoin Monitor with Sample Data")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Check if data already exists
|
||||||
|
if BitcoinPrice.objects.exists():
|
||||||
|
print("⚠️ Database already contains data.")
|
||||||
|
response = input("Clear existing data? (y/N): ")
|
||||||
|
if response.lower() != 'y':
|
||||||
|
print("Exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load historical data
|
||||||
|
print("\n📊 Loading historical Bitcoin data...")
|
||||||
|
fetcher = HistoricalDataFetcher()
|
||||||
|
|
||||||
|
# Try to fetch real data
|
||||||
|
historical_data = fetcher.fetch_historical_data(days=90) # 3 months
|
||||||
|
|
||||||
|
if not historical_data:
|
||||||
|
print("⚠️ Could not fetch real data. Generating synthetic data...")
|
||||||
|
historical_data = fetcher.generate_test_data(days=90)
|
||||||
|
|
||||||
|
# Save data
|
||||||
|
save_stats = fetcher.save_historical_data(
|
||||||
|
historical_data=historical_data,
|
||||||
|
clear_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✅ Saved {save_stats['saved']} price records")
|
||||||
|
|
||||||
|
# Run analysis
|
||||||
|
print("\n📈 Running market analysis...")
|
||||||
|
analyzer = MarketAnalyzer()
|
||||||
|
|
||||||
|
analyses_created = 0
|
||||||
|
for period in ['hourly', 'daily', 'weekly']:
|
||||||
|
analysis = analyzer.analyze_market(period)
|
||||||
|
if analysis:
|
||||||
|
analyses_created += 1
|
||||||
|
print(f" Created {period} analysis: {analysis.status}")
|
||||||
|
|
||||||
|
print(f"✅ Created {analyses_created} market analyses")
|
||||||
|
|
||||||
|
# Setup notification preferences
|
||||||
|
print("\n📧 Setting up notification preferences...")
|
||||||
|
|
||||||
|
test_emails = [
|
||||||
|
'ali.c.zeybek@gmail.com',
|
||||||
|
'alican@alicanzeybek.xyz',
|
||||||
|
]
|
||||||
|
|
||||||
|
for email in test_emails:
|
||||||
|
pref, created = NotificationPreference.objects.get_or_create(
|
||||||
|
email_address=email,
|
||||||
|
defaults={
|
||||||
|
'receive_event_alerts': True,
|
||||||
|
'receive_system_alerts': True,
|
||||||
|
'receive_daily_digest': True,
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
print(f" Created preferences for {email}")
|
||||||
|
else:
|
||||||
|
print(f" Preferences already exist for {email}")
|
||||||
|
|
||||||
|
# Display summary
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("🎉 Sample Data Initialization Complete!")
|
||||||
|
print("\nSummary:")
|
||||||
|
print(f" • Bitcoin price records: {BitcoinPrice.objects.count()}")
|
||||||
|
print(f" • Market analyses: {MarketAnalysis.objects.count()}")
|
||||||
|
print(f" • Notification preferences: {NotificationPreference.objects.count()}")
|
||||||
|
|
||||||
|
print("\nNext steps:")
|
||||||
|
print(" 1. Start the server: python manage.py runserver")
|
||||||
|
print(" 2. Visit dashboard: http://localhost:8000/")
|
||||||
|
print(" 3. Check admin: http://localhost:8000/admin/")
|
||||||
|
print(" 4. Load more data: python manage.py load_historical_data --days 365")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
initialize_sample_data()
|
||||||
24
start_workers.sh
Executable file
24
start_workers.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Start Redis (if not already running)
|
||||||
|
echo "Checking Redis..."
|
||||||
|
if ! redis-cli ping > /dev/null 2>&1; then
|
||||||
|
echo "Starting Redis..."
|
||||||
|
redis-server --daemonize yes
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Celery worker
|
||||||
|
echo "Starting Celery worker..."
|
||||||
|
celery -A config worker --loglevel=info --pool=solo &
|
||||||
|
|
||||||
|
# Start Celery beat scheduler
|
||||||
|
echo "Starting Celery beat scheduler..."
|
||||||
|
celery -A config beat --loglevel=info &
|
||||||
|
|
||||||
|
# Start Django server
|
||||||
|
echo "Starting Django server..."
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Wait for all processes
|
||||||
|
wait
|
||||||
28
testmail.py
Normal file
28
testmail.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
sender_email = "ali.c.zeybek@gmail.com"
|
||||||
|
password = "crry mcte umao njgq".replace(" ", "") # Remove spaces
|
||||||
|
receiver_email = "ali.c.zeybek@gmail.com"
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = "Test Email"
|
||||||
|
msg["From"] = sender_email
|
||||||
|
msg["To"] = receiver_email
|
||||||
|
|
||||||
|
text = "This is a test email"
|
||||||
|
html = "<html><body><h1>Test Email</h1></body></html>"
|
||||||
|
|
||||||
|
msg.attach(MIMEText(text, "plain"))
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = smtplib.SMTP("smtp.gmail.com", 587)
|
||||||
|
server.starttls()
|
||||||
|
server.login(sender_email, password)
|
||||||
|
server.sendmail(sender_email, receiver_email, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
print("✅ Email sent successfully!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed: {e}")
|
||||||
Reference in New Issue
Block a user