From 5d437e5e288ec187b850e20a2bb8d7076bd2cda6 Mon Sep 17 00:00:00 2001 From: Ali Can Zeybek Date: Fri, 16 Jan 2026 22:20:18 +0300 Subject: [PATCH] first commit --- .gitignore | 33 ++ Pipfile | 11 + api/__init__.py | 0 api/admin.py | 3 + api/apps.py | 5 + api/migrations/__init__.py | 0 api/models.py | 3 + api/serializers.py | 69 ++++ api/tests.py | 3 + api/urls.py | 23 ++ api/views.py | 260 ++++++++++++ celerybeat-schedule-shm | Bin 0 -> 32768 bytes celerybeat-schedule-wal | Bin 0 -> 135992 bytes config/__init__.py | 3 + config/asgi.py | 16 + config/celery.py | 68 ++++ config/services/data_fetcher.py | 99 +++++ config/settings.py | 175 ++++++++ config/tasks.py | 229 +++++++++++ config/templates/dashboard.html | 52 +++ config/urls.py | 30 ++ config/views.py | 9 + config/wsgi.py | 16 + mailout | 2 + manage.py | 22 + monitor/__init__.py | 0 monitor/admin.py | 130 ++++++ monitor/apps.py | 5 + .../management/commands/check_data_quality.py | 156 ++++++++ .../commands/load_historical_data.py | 211 ++++++++++ .../management/commands/load_sample_data.py | 33 ++ .../management/commands/send_test_email.py | 28 ++ .../commands/setup_notifications.py | 43 ++ monitor/migrations/0001_initial.py | 27 ++ monitor/migrations/0002_marketanalysis.py | 37 ++ monitor/migrations/0003_systemstatus.py | 30 ++ ...otificationpreference_emailnotification.py | 49 +++ monitor/migrations/__init__.py | 0 monitor/models.py | 167 ++++++++ monitor/services/analyzer.py | 124 ++++++ monitor/services/email_service.py | 376 ++++++++++++++++++ monitor/services/historical_data.py | 301 ++++++++++++++ monitor/tasks.py | 173 ++++++++ monitor/templates/emails/base.html | 199 +++++++++ monitor/templates/emails/daily_digest.html | 100 +++++ monitor/templates/emails/event_alert.html | 83 ++++ monitor/templates/emails/system_alert.html | 52 +++ .../templates/monitor/analysis_result.html | 108 +++++ monitor/templates/monitor/bitcoin_data.html | 294 ++++++++++++++ monitor/templates/monitor/view_analysis.html | 195 +++++++++ monitor/tests.py | 3 + monitor/views.py | 207 ++++++++++ requirements.txt | 41 ++ scripts/initialize_sample_data.py | 108 +++++ start_workers.sh | 24 ++ testmail.py | 28 ++ 56 files changed, 4463 insertions(+) create mode 100644 .gitignore create mode 100644 Pipfile create mode 100644 api/__init__.py create mode 100644 api/admin.py create mode 100644 api/apps.py create mode 100644 api/migrations/__init__.py create mode 100644 api/models.py create mode 100644 api/serializers.py create mode 100644 api/tests.py create mode 100644 api/urls.py create mode 100644 api/views.py create mode 100644 celerybeat-schedule-shm create mode 100644 celerybeat-schedule-wal create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/services/data_fetcher.py create mode 100644 config/settings.py create mode 100644 config/tasks.py create mode 100644 config/templates/dashboard.html create mode 100644 config/urls.py create mode 100644 config/views.py create mode 100644 config/wsgi.py create mode 100644 mailout create mode 100755 manage.py create mode 100644 monitor/__init__.py create mode 100644 monitor/admin.py create mode 100644 monitor/apps.py create mode 100644 monitor/management/commands/check_data_quality.py create mode 100644 monitor/management/commands/load_historical_data.py create mode 100644 monitor/management/commands/load_sample_data.py create mode 100644 monitor/management/commands/send_test_email.py create mode 100644 monitor/management/commands/setup_notifications.py create mode 100644 monitor/migrations/0001_initial.py create mode 100644 monitor/migrations/0002_marketanalysis.py create mode 100644 monitor/migrations/0003_systemstatus.py create mode 100644 monitor/migrations/0004_notificationpreference_emailnotification.py create mode 100644 monitor/migrations/__init__.py create mode 100644 monitor/models.py create mode 100644 monitor/services/analyzer.py create mode 100644 monitor/services/email_service.py create mode 100644 monitor/services/historical_data.py create mode 100644 monitor/tasks.py create mode 100644 monitor/templates/emails/base.html create mode 100644 monitor/templates/emails/daily_digest.html create mode 100644 monitor/templates/emails/event_alert.html create mode 100644 monitor/templates/emails/system_alert.html create mode 100644 monitor/templates/monitor/analysis_result.html create mode 100644 monitor/templates/monitor/bitcoin_data.html create mode 100644 monitor/templates/monitor/view_analysis.html create mode 100644 monitor/tests.py create mode 100644 monitor/views.py create mode 100644 requirements.txt create mode 100755 scripts/initialize_sample_data.py create mode 100755 start_workers.sh create mode 100644 testmail.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea91aa6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..d61ea53 --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..d87006d --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..9f22580 --- /dev/null +++ b/api/serializers.py @@ -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() diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..7c7a3a5 --- /dev/null +++ b/api/urls.py @@ -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'), +] diff --git a/api/views.py b/api/views.py new file mode 100644 index 0000000..1496871 --- /dev/null +++ b/api/views.py @@ -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 diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm new file mode 100644 index 0000000000000000000000000000000000000000..6acac31c408b99cf1d68967d632e7109ce24083f GIT binary patch literal 32768 zcmeI)OD;q~6b9g5U)rO+pI2+Nk&y)$TZ08iBvxQ(Vh8qO$k0MaY{8T&k&dZ}9^CIw zPR>oGQdRd`z-iykQnOxarnHmNn^L#u7uPR0gXf3w-DUs&_5Gtcy&63ZpV~j}FGqc+ zp8NSLKg*L=?oO{%y?a`H@)#ck2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009F3Bv7YL2!U=2^fH@% z7P6G(td9mp3KU(*NzU>c~c3kXh?-b{p zozJE2meExUec`mNgr+k37l;YeiD^>B(8SO-(V?}hXcc0hiEV6S)1Euq zjZ+Ymh)m7(dy1Xsp7T9MP=V z&|bs(2Nk1I=J3LH$&0P`6wDS6%(pL-OcY9SUJH=pK^;=h$`_KOG z^({49|M&d!*9-o1{89b;i%$Hu-LI@(O7Kf82q1s}0tg_000IagfB*srAP`Og{~Cc@ z&k;nHKAryh%eSpd`eWs~KseuVMuh+Z2q1s}0tg_000IagfWWlCjJ!bM-%q_F|L#D) zpBIQIZ%N{d1px#QKmY**5I_I{1Q0*~0R+M!phV=Jt8~k;O}(@)HtA)=ux8{1R($1V zr{~&R{^REbBFf(+@x_7w0tg_000IagfB*srAbBnJ&D@I0Wqu@d4cc6uHM*x z|B^fWyg*brDe?m1i3I@!5I_I{1Q0*~0R#|0009Ipb%EZf+&iSIyLG!{7A;lX7dx7$ z_1SG2&fJh0^fDir7x+Vu`grk`&wa+v3q+N(l5&>Bz@`4i(?0|dKmY**5I_I{1Q0*~ z0R%o&K#_YR4HpceACecCcwF85-It&JwVxM|l_!I|fcVCO00IagfB*srAbs~+dOjDnq7m$@#gS>$F#)1F>2q1s} z0tg_000IagfB*vXP9QFKMx4F!$H)sTKeIb)Ui0QUKQ9nd9<1dBM1chX1Q0*~0R#|0 z009ILKmY**{?7te#N^(EWhbZVmNr_@^ZQ~4Gjp34xbizAH`#B$)bHm7V#=>-c>z&i zK>z^+5I_I{1Q0*~0R#|00D((fzz+sATt$$Bty&%>yqq^pJZu_>Jq36p5eXW=VzT(~I4Hyx3@p#cPouZv_ zw9;59BU)6eEvoevRey`;w&XQOcg%6Uv3Q@iCfR(hEzz~2v+3d2#Fo*rS#V6NTl^fiWpB~aO>1XS6mJ*Bb$(IQC)BbvVdlmPx_6Ja)vNxyc{bvg{YO@EJ8L5- z)w(VkZmE|=3r5^c_S!rWuM^-u@c0RPpe_k@_O^UwgdnQaZ8I{F=7&HDYg1{Dg8j81hy14}+E))2+OkE9jb4o(M*{%2?Id z{o@ZzcYl%FoU@CTqm2sR0XH(}xsf69Sn3%g#)#kYq&FHci{U)?SioD(eByZlaTG7O z@JN!}+CgVf^0R#|0009IL zKmdWT3Xm5dFA$dd3y5QMW-Oq)S?(Nk_KGw>bM5%8-eDp$o4ml*<%iow^3UIvR9>CV z3!LNr0%1KfGA;xVKmY**5I_I{1Q0*~f%z`*VR->Dwt7&YIyFJ_N96^+a47lDLf0M7 zw<`|@d4XzLKsh$wmFPbL2q1s}0tg_000IbH?gHcm$P3KJynr}vXJ!MgXpwstHq1o0 zFLp5ViRJ~a>>7G6XFc`lq=*Ov^AY&>7dWT<{&GKqFn$COKmY**5I_I{1Q0*~0R%2h zfyTUm=wUq{aKY3BEf<*=n96_lrzakLW3XK`6>Iy4y5?xyfw+@b8U&P6`f5Fzb3Yfmd%1=T4El7=3Tm@IgTxUj@z=gXz8Z4vnYzU zi{d)JDC!ex*_tqOV+Gy2$J^>v|J^(r@yq@rE4iJu5imi4mzCWP({kp?1O;=Q7g%o$ zci(t!_d7{(yvLLmB;{@81@Z#nI{7gg1Q0*~0R#|0009ILKmdV|3N%Cq60^(;JaP6$ z^XTfobX4;KC+m5ElOYvjSO_4100IagfB*srAP_ErDJ>c6x%Z~&48Tb;0QEe8ND&xu zH{s_J@_ND1#ClfR*x8Yl4u}B3p-uktl1Xn;-ypiUGEX09TJIN-L9YkO3`))YOT%e=s9 zWA*uAi0H@h2kOI+j74s9&MsPxHY(nW0XH(}xsf69Sn3(K8n^oJliq0XmKc2-pL;BL zE6SNUzrVmYR<6)WZ#;0Cyg;~4j*JEY1Q0*~0R#|0009ILK;SYEXvhmBXPFmpCLSqw z9oll5yufAfd0~hMAb`2mV_B zLwl6Gz?{xU(3<)7uEbrHACVUb-wBctA%Fk^2q1s}0tg_000IbvT%aK@&^pV!z|(*4 zI@P`Z&nkI=kbhPf9s&p;fB*srAbKeIV6x^+Q?m0wMf-FeC&JKmY**5I_I{1i~*cr77eELYNom zTF{&>*7E{!@&a?37s#(!S^in;uII@Mg#U!e2oXR40R#|0009ILKmY**LMG6V7f>!T GFYrHwBuGgB literal 0 HcmV?d00001 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..6a2d727 --- /dev/null +++ b/config/celery.py @@ -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}') diff --git a/config/services/data_fetcher.py b/config/services/data_fetcher.py new file mode 100644 index 0000000..ca61478 --- /dev/null +++ b/config/services/data_fetcher.py @@ -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 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..25cfd1b --- /dev/null +++ b/config/settings.py @@ -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 ') + +# 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/' diff --git a/config/tasks.py b/config/tasks.py new file mode 100644 index 0000000..f1eceaa --- /dev/null +++ b/config/tasks.py @@ -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)} diff --git a/config/templates/dashboard.html b/config/templates/dashboard.html new file mode 100644 index 0000000..1fcf477 --- /dev/null +++ b/config/templates/dashboard.html @@ -0,0 +1,52 @@ + + + + Bitcoin Monitor + + + +
+

₿ Bitcoin Monitor

+

Welcome to your Bitcoin monitoring dashboard!

+ +
+

Status: Running

+

This is a simple Django app for monitoring Bitcoin prices.

+
+ +

Next Steps:

+
    +
  • Connect to Bitcoin API
  • +
  • Display real-time prices
  • +
  • Add price charts
  • +
  • Set up alerts
  • +
+ +

Check the API: Hello World

+
+ + diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..03d8310 --- /dev/null +++ b/config/urls.py @@ -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')), +] diff --git a/config/views.py b/config/views.py new file mode 100644 index 0000000..d07b726 --- /dev/null +++ b/config/views.py @@ -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') diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/mailout b/mailout new file mode 100644 index 0000000..7117a18 --- /dev/null +++ b/mailout @@ -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') diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -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() diff --git a/monitor/__init__.py b/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/monitor/admin.py b/monitor/admin.py new file mode 100644 index 0000000..802fd3d --- /dev/null +++ b/monitor/admin.py @@ -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" diff --git a/monitor/apps.py b/monitor/apps.py new file mode 100644 index 0000000..7a5aa7e --- /dev/null +++ b/monitor/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MonitorConfig(AppConfig): + name = 'monitor' diff --git a/monitor/management/commands/check_data_quality.py b/monitor/management/commands/check_data_quality.py new file mode 100644 index 0000000..ad8016e --- /dev/null +++ b/monitor/management/commands/check_data_quality.py @@ -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!")) diff --git a/monitor/management/commands/load_historical_data.py b/monitor/management/commands/load_historical_data.py new file mode 100644 index 0000000..f743e4c --- /dev/null +++ b/monitor/management/commands/load_historical_data.py @@ -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") diff --git a/monitor/management/commands/load_sample_data.py b/monitor/management/commands/load_sample_data.py new file mode 100644 index 0000000..81a626f --- /dev/null +++ b/monitor/management/commands/load_sample_data.py @@ -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') + ) diff --git a/monitor/management/commands/send_test_email.py b/monitor/management/commands/send_test_email.py new file mode 100644 index 0000000..f3ab132 --- /dev/null +++ b/monitor/management/commands/send_test_email.py @@ -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}')) diff --git a/monitor/management/commands/setup_notifications.py b/monitor/management/commands/setup_notifications.py new file mode 100644 index 0000000..f9b6ce1 --- /dev/null +++ b/monitor/management/commands/setup_notifications.py @@ -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)') + ) diff --git a/monitor/migrations/0001_initial.py b/monitor/migrations/0001_initial.py new file mode 100644 index 0000000..c6d6321 --- /dev/null +++ b/monitor/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/monitor/migrations/0002_marketanalysis.py b/monitor/migrations/0002_marketanalysis.py new file mode 100644 index 0000000..aaca8f8 --- /dev/null +++ b/monitor/migrations/0002_marketanalysis.py @@ -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'], + }, + ), + ] diff --git a/monitor/migrations/0003_systemstatus.py b/monitor/migrations/0003_systemstatus.py new file mode 100644 index 0000000..b199a04 --- /dev/null +++ b/monitor/migrations/0003_systemstatus.py @@ -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'], + }, + ), + ] diff --git a/monitor/migrations/0004_notificationpreference_emailnotification.py b/monitor/migrations/0004_notificationpreference_emailnotification.py new file mode 100644 index 0000000..35679c5 --- /dev/null +++ b/monitor/migrations/0004_notificationpreference_emailnotification.py @@ -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')], + }, + ), + ] diff --git a/monitor/migrations/__init__.py b/monitor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/monitor/models.py b/monitor/models.py new file mode 100644 index 0000000..b5e1782 --- /dev/null +++ b/monitor/models.py @@ -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']), + ] diff --git a/monitor/services/analyzer.py b/monitor/services/analyzer.py new file mode 100644 index 0000000..dc45d9d --- /dev/null +++ b/monitor/services/analyzer.py @@ -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 diff --git a/monitor/services/email_service.py b/monitor/services/email_service.py new file mode 100644 index 0000000..8713975 --- /dev/null +++ b/monitor/services/email_service.py @@ -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.") diff --git a/monitor/services/historical_data.py b/monitor/services/historical_data.py new file mode 100644 index 0000000..15a8800 --- /dev/null +++ b/monitor/services/historical_data.py @@ -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 diff --git a/monitor/tasks.py b/monitor/tasks.py new file mode 100644 index 0000000..1e5de2c --- /dev/null +++ b/monitor/tasks.py @@ -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)} diff --git a/monitor/templates/emails/base.html b/monitor/templates/emails/base.html new file mode 100644 index 0000000..54df3d1 --- /dev/null +++ b/monitor/templates/emails/base.html @@ -0,0 +1,199 @@ + + + + + + {% block title %}Bitcoin Monitor{% endblock %} + + + + + + diff --git a/monitor/templates/emails/daily_digest.html b/monitor/templates/emails/daily_digest.html new file mode 100644 index 0000000..640e9ba --- /dev/null +++ b/monitor/templates/emails/daily_digest.html @@ -0,0 +1,100 @@ +{% extends "emails/base.html" %} + +{% block title %}Daily Digest - Bitcoin Monitor{% endblock %} + +{% block content %} +
+ šŸ“Š DAILY DIGEST: {{ date|date:"F d, Y" }} +
+ +
+

24-Hour Summary

+

{{ summary_period }}

+
+ +
+
Market Status
+
+ {{ market_status|upper }} +
+
+ {% if market_status == 'dip' %}Price below yearly average + {% elif market_status == 'peak' %}Price above yearly average + {% else %}Price within normal range{% endif %} +
+
+ +
+
+
${{ current_price|floatformat:2 }}
+
Current Price
+
+
+
${{ daily_high|floatformat:2 }}
+
24h High
+
+
+
${{ daily_low|floatformat:2 }}
+
24h Low
+
+
+
{{ daily_change|floatformat:1 }}%
+
24h Change
+
+
+ +
+

šŸ“ˆ Market Activity

+ +
+
+ Events Today: + {{ events_count }} +
+
+ Price Fetches: + {{ price_fetches }} +
+
+ System Uptime: + {{ uptime_percentage|floatformat:1 }}% +
+
+ + {% if events_today %} +
+

āš ļø Events Today

+
    + {% for event in events_today %} +
  • + {{ event.type|title }} at + ${{ event.price|floatformat:2 }} ({{ event.time|time:"H:i" }}) +
  • + {% endfor %} +
+
+ {% endif %} +
+ + {% if market_insight %} +
+

šŸ“‹ Market Insight

+

{{ market_insight }}

+
+ {% endif %} + +
+

+ Next digest: Tomorrow at 08:00 UTC +

+
+ + +{% endblock %} diff --git a/monitor/templates/emails/event_alert.html b/monitor/templates/emails/event_alert.html new file mode 100644 index 0000000..7034447 --- /dev/null +++ b/monitor/templates/emails/event_alert.html @@ -0,0 +1,83 @@ +{% extends "emails/base.html" %} + +{% block title %}{{ alert_type }} Alert - Bitcoin Monitor{% endblock %} + +{% block content %} +
+ {% 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 %} +
+ +
+
Current Bitcoin Price
+
${{ current_price|floatformat:2 }}
+
+ {% if alert_type == 'dip' %}šŸ“‰ Below threshold{% else %}šŸ“ˆ Above threshold{% endif %} +
+
+ +
+
+
{{ threshold_percent }}%
+
Threshold
+
+
+
${{ yearly_average|floatformat:2 }}
+
Yearly Average
+
+
+
${{ lower_threshold|floatformat:2 }}
+
Lower Bound
+
+
+
${{ upper_threshold|floatformat:2 }}
+
Upper Bound
+
+
+ +
+

Event Details

+ + + + + + + + + + + + + + + + + +
Event Type: + {{ alert_type|upper }} +
Detected At: + {{ detected_at|date:"M d, Y H:i" }} UTC +
Price Deviation: + {{ percent_change|floatformat:1 }}% +
Previous Status:{{ previous_status|default:"N/A"|title }}
+
+ + {% if recommendation %} +
+

šŸ’” Recommendation

+

{{ recommendation }}

+
+ {% endif %} + + +{% endblock %} diff --git a/monitor/templates/emails/system_alert.html b/monitor/templates/emails/system_alert.html new file mode 100644 index 0000000..91ec83f --- /dev/null +++ b/monitor/templates/emails/system_alert.html @@ -0,0 +1,52 @@ +{% extends "emails/base.html" %} + +{% block title %}System Alert - Bitcoin Monitor{% endblock %} + +{% block content %} +
+ āš ļø SYSTEM ALERT: {{ alert_title }} +
+ +
+

Issue Details

+
+

+ {{ alert_message }} +

+
+
+ +
+
+
{{ affected_component }}
+
Affected Component
+
+
+
{{ severity|upper }}
+
Severity
+
+
+
{{ occurred_at|date:"H:i" }}
+
Time (UTC)
+
+
+
{{ error_code|default:"N/A" }}
+
Error Code
+
+
+ +
+

šŸ› ļø Troubleshooting Steps

+
    + {% for step in troubleshooting_steps %} +
  • {{ step }}
  • + {% endfor %} +
+
+ + +{% endblock %} diff --git a/monitor/templates/monitor/analysis_result.html b/monitor/templates/monitor/analysis_result.html new file mode 100644 index 0000000..4d7bf42 --- /dev/null +++ b/monitor/templates/monitor/analysis_result.html @@ -0,0 +1,108 @@ + + + + Analysis Results + + + +
+
+

āœ“ Analysis Complete

+

Market analysis has been successfully executed

+
+ +
+

{{ message }}

+
+ +

Analysis Results

+ + {% for analysis in analyses %} +
+

{{ analysis.period|title }} Analysis

+

Status: + + {{ analysis.status|upper }} + +

+

Current Price: ${{ analysis.current_price }}

+

Average Price: ${{ analysis.average_price }}

+

Threshold: {{ analysis.threshold_percent }}%

+

Range: ${{ analysis.lower_threshold }} - ${{ analysis.upper_threshold }}

+ {% if analysis.is_event %} +

āš ļø Event Detected: {{ analysis.event_type|title }}

+ {% endif %} +

Analyzed at: {{ analysis.timestamp }}

+
+ {% endfor %} + + +
+ + diff --git a/monitor/templates/monitor/bitcoin_data.html b/monitor/templates/monitor/bitcoin_data.html new file mode 100644 index 0000000..386b18d --- /dev/null +++ b/monitor/templates/monitor/bitcoin_data.html @@ -0,0 +1,294 @@ + + + + + + + + Bitcoin Price Monitor + + + +
+
+

₿ Bitcoin Price Monitor

+

Real-time Bitcoin price tracking and historical data

+
+ +
+

Current Status

+

Latest Price: ${{ stats.latest_price }}

+

Last Updated: {{ stats.latest_time }}

+

Total Records: {{ stats.total_records }}

+ +
+ + + + Admin Panel + + + Run Analysis + + + View Analysis + +
+ +
+
+ +

Recent Prices (Last 10)

+ {% if prices %} + + + + + + + + + + + {% for price in prices %} + + + + + + + {% endfor %} + +
TimestampPrice (USD)VolumeMarket Cap
{{ price.timestamp }}${{ price.price_usd }}{% if price.volume %}${{ price.volume }}{% else %}-{% endif %}{% if price.market_cap %}${{ price.market_cap }}{% else %}-{% endif %}
+ {% else %} +

No price data available yet. Click "Fetch Current Price" to get started!

+ {% endif %} +
+
+

API Status

+

Current Price: Loading...

+

Status: Loading...

+

Yearly Average: Loading...

+ +
+ +
+

Recent Events

+
    +
  • Loading events...
  • +
+
+ + + + + diff --git a/monitor/templates/monitor/view_analysis.html b/monitor/templates/monitor/view_analysis.html new file mode 100644 index 0000000..3da4b73 --- /dev/null +++ b/monitor/templates/monitor/view_analysis.html @@ -0,0 +1,195 @@ + + + + Market Analysis + + + +
+
+

šŸ“Š Market Analysis Dashboard

+

Comprehensive Bitcoin market analysis across different time periods

+
+ + + +

Latest Analysis Summary

+ + {% if summary %} +
+ {% for period, data in summary.items %} +
+

{{ period|title }} Analysis + + {{ data.status|upper }} + +

+

Current Price: ${{ data.current_price|floatformat:2 }}

+

Average Price: ${{ data.average_price|floatformat:2 }}

+

Threshold: {{ data.threshold_percent }}%

+ {% if data.is_event %} +

āš ļø Event Active

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

No analysis data available

+

Click "Run New Analysis" to generate your first analysis report.

+
+ {% endif %} + +

Recent Analyses

+ + {% if all_analyses %} + + + + + + + + + + + + + + {% for analysis in all_analyses %} + + + + + + + + + + {% endfor %} + +
TimePeriodStatusCurrent PriceAverage PriceThresholdEvent
{{ analysis.timestamp|date:"M d, H:i" }}{{ analysis.period }} + + {{ analysis.status|upper }} + + ${{ analysis.current_price|floatformat:2 }}${{ analysis.average_price|floatformat:2 }}{{ analysis.threshold_percent }}% + {% if analysis.is_event %} + {{ analysis.event_type|title }} + {% else %} + - + {% endif %} +
+ {% else %} +
+

No analysis records found. Run an analysis to see results here.

+
+ {% endif %} +
+ + diff --git a/monitor/tests.py b/monitor/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/monitor/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/monitor/views.py b/monitor/views.py new file mode 100644 index 0000000..8b05cc7 --- /dev/null +++ b/monitor/views.py @@ -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 + }) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eec2986 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/initialize_sample_data.py b/scripts/initialize_sample_data.py new file mode 100755 index 0000000..56d045a --- /dev/null +++ b/scripts/initialize_sample_data.py @@ -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() diff --git a/start_workers.sh b/start_workers.sh new file mode 100755 index 0000000..d104662 --- /dev/null +++ b/start_workers.sh @@ -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 diff --git a/testmail.py b/testmail.py new file mode 100644 index 0000000..5090681 --- /dev/null +++ b/testmail.py @@ -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 = "

Test Email

" + +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}")