Karan6124 commited on
Commit
9bf8bc5
·
1 Parent(s): b009897

feat: implement real-time price alerts via email with 2-minute reminder loops

Browse files

- models.py: Add is_triggered and last_notified_at columns to the Alert database model.
- crud.py: Add trigger_alert and update_alert_notification_time database handlers.
- crud.py: Check active alerts and immediately notify on crossover during insert_stock_candle execution.
- email_service.py: Create SMTP email dispatcher with professional HTML template and terminal log fallback.
- celery_app.py: Implement process_triggered_alerts task scheduled to run every 120 seconds.
- Alembic: Generate and execute database migrations to apply schema updates on Postgres.

alembic/versions/15639881888f_add_alert_trigger_columns.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add_alert_trigger_columns
2
+
3
+ Revision ID: 15639881888f
4
+ Revises: 1ffb20394fc4
5
+ Create Date: 2026-07-03 12:55:47.934133
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '15639881888f'
16
+ down_revision: Union[str, Sequence[str], None] = '1ffb20394fc4'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.add_column('alerts', sa.Column('is_triggered', sa.Boolean(), nullable=False))
25
+ op.add_column('alerts', sa.Column('last_notified_at', sa.DateTime(timezone=True), nullable=True))
26
+ # ### end Alembic commands ###
27
+
28
+
29
+ def downgrade() -> None:
30
+ """Downgrade schema."""
31
+ # ### commands auto generated by Alembic - please adjust! ###
32
+ op.drop_column('alerts', 'last_notified_at')
33
+ op.drop_column('alerts', 'is_triggered')
34
+ # ### end Alembic commands ###
backend/app/database/crud.py CHANGED
@@ -158,6 +158,24 @@ async def deactivate_alert(db: AsyncSession, alert_id: uuid.UUID) -> bool:
158
  await db.commit()
159
  return result.rowcount > 0
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  # ==========================================
163
  # STOCK HISTORY OPERATIONS
@@ -193,6 +211,41 @@ async def insert_stock_candle(db: AsyncSession, candle: schemas.StockHistoryBase
193
  await db.execute(stmt)
194
  await db.commit()
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  # ==========================================
198
  # PAYMENT TRANSACTION OPERATIONS
 
158
  await db.commit()
159
  return result.rowcount > 0
160
 
161
+ async def trigger_alert(db: AsyncSession, alert_id: uuid.UUID) -> bool:
162
+ result = await db.execute(
163
+ update(models.Alert)
164
+ .where(models.Alert.id == alert_id)
165
+ .values(is_triggered=True, last_notified_at=func.now())
166
+ )
167
+ await db.commit()
168
+ return result.rowcount > 0
169
+
170
+ async def update_alert_notification_time(db: AsyncSession, alert_id: uuid.UUID) -> bool:
171
+ result = await db.execute(
172
+ update(models.Alert)
173
+ .where(models.Alert.id == alert_id)
174
+ .values(last_notified_at=func.now())
175
+ )
176
+ await db.commit()
177
+ return result.rowcount > 0
178
+
179
 
180
  # ==========================================
181
  # STOCK HISTORY OPERATIONS
 
211
  await db.execute(stmt)
212
  await db.commit()
213
 
214
+ # Check and trigger alerts for this ticker
215
+ alert_stmt = (
216
+ select(models.Alert)
217
+ .where(models.Alert.ticker == candle.ticker.upper())
218
+ .where(models.Alert.is_active == True)
219
+ .where(models.Alert.is_triggered == False)
220
+ )
221
+ alert_result = await db.execute(alert_stmt)
222
+ active_alerts = list(alert_result.scalars().all())
223
+
224
+ from backend.app.services.email_service import send_price_alert_email
225
+ for alert in active_alerts:
226
+ triggered = False
227
+ if alert.condition == "above" and candle.close >= alert.target_price:
228
+ triggered = True
229
+ elif alert.condition == "below" and candle.close <= alert.target_price:
230
+ triggered = True
231
+
232
+ if triggered:
233
+ alert.is_triggered = True
234
+ alert.last_notified_at = func.now()
235
+ db.add(alert)
236
+ await db.commit()
237
+
238
+ # Retrieve user to get their email address
239
+ user = await get_user(db, alert.user_id)
240
+ if user:
241
+ send_price_alert_email(
242
+ to_email=user.email,
243
+ ticker=alert.ticker,
244
+ condition=alert.condition,
245
+ target_price=alert.target_price,
246
+ current_price=candle.close
247
+ )
248
+
249
 
250
  # ==========================================
251
  # PAYMENT TRANSACTION OPERATIONS
backend/app/database/models.py CHANGED
@@ -83,6 +83,8 @@ class Alert(Base):
83
  target_price: Mapped[float] = mapped_column(Float, nullable=False)
84
  condition: Mapped[str] = mapped_column(String(20), nullable=False) # "above" or "below"
85
  is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
 
 
86
  created_at: Mapped[datetime.datetime] = mapped_column(
87
  DateTime(timezone=True),
88
  server_default=func.now(),
 
83
  target_price: Mapped[float] = mapped_column(Float, nullable=False)
84
  condition: Mapped[str] = mapped_column(String(20), nullable=False) # "above" or "below"
85
  is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
86
+ is_triggered: Mapped[bool] = mapped_column(default=False, nullable=False)
87
+ last_notified_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
88
  created_at: Mapped[datetime.datetime] = mapped_column(
89
  DateTime(timezone=True),
90
  server_default=func.now(),
backend/app/services/celery_app.py CHANGED
@@ -98,4 +98,73 @@ def cleanup_old_history(days_to_keep: int= 7) -> str:
98
  """
99
  Celery task that clears old stock history to save NeonDB storage space.
100
  """
101
- return asyncio.run(_async_cleanup_old_history(days_to_keep))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  """
99
  Celery task that clears old stock history to save NeonDB storage space.
100
  """
101
+ return asyncio.run(_async_cleanup_old_history(days_to_keep))
102
+
103
+ # Periodic triggered alerts processor
104
+ async def _async_process_triggered_alerts() -> str:
105
+ """
106
+ Checks all active alerts that have been triggered and emails reminders
107
+ every 2 minutes until deactivated.
108
+ """
109
+ from sqlalchemy import select
110
+ from backend.app.services.email_service import send_price_alert_email
111
+
112
+ async with SessionLocal() as db:
113
+ stmt = (
114
+ select(models.Alert)
115
+ .where(models.Alert.is_active == True)
116
+ .where(models.Alert.is_triggered == True)
117
+ )
118
+ result = await db.execute(stmt)
119
+ triggered_alerts = list(result.scalars().all())
120
+
121
+ sent_count = 0
122
+ now = datetime.datetime.now(datetime.timezone.utc)
123
+
124
+ for alert in triggered_alerts:
125
+ # Check throttle: notify if last_notified_at is null or older than 2 minutes
126
+ should_notify = False
127
+ if not alert.last_notified_at:
128
+ should_notify = True
129
+ else:
130
+ delta = now - alert.last_notified_at
131
+ if delta >= datetime.timedelta(minutes=2):
132
+ should_notify = True
133
+
134
+ if should_notify:
135
+ user = await crud.get_user(db, alert.user_id)
136
+ if not user:
137
+ continue
138
+
139
+ # Fetch latest price
140
+ history = await crud.get_stock_history(db, alert.ticker, limit=1)
141
+ current_price = history[0].close if history else alert.target_price
142
+
143
+ success = send_price_alert_email(
144
+ to_email=user.email,
145
+ ticker=alert.ticker,
146
+ condition=alert.condition,
147
+ target_price=alert.target_price,
148
+ current_price=current_price
149
+ )
150
+
151
+ if success:
152
+ await crud.update_alert_notification_time(db, alert.id)
153
+ sent_count += 1
154
+
155
+ return f"Processed triggered alerts. Sent {sent_count} email notifications."
156
+
157
+ @celery_app.task(name="tasks.process_triggered_alerts")
158
+ def process_triggered_alerts() -> str:
159
+ """
160
+ Celery task run periodically (every 2 minutes) to process triggered alerts.
161
+ """
162
+ return asyncio.run(_async_process_triggered_alerts())
163
+
164
+ # Configure Celery Beat Schedule
165
+ celery_app.conf.beat_schedule = {
166
+ "process-triggered-alerts-every-2-min": {
167
+ "task": "tasks.process_triggered_alerts",
168
+ "schedule": 120.0, # 120 seconds = 2 minutes
169
+ }
170
+ }
backend/app/services/email_service.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import smtplib
2
+ import logging
3
+ import datetime
4
+ from email.mime.text import MIMEText
5
+ from email.mime.multipart import MIMEMultipart
6
+ from backend.app.config.settings import settings
7
+
8
+ logger = logging.getLogger("quantiq.email")
9
+
10
+ def send_price_alert_email(to_email: str, ticker: str, condition: str, target_price: float, current_price: float) -> bool:
11
+ """
12
+ Sends a price alert notification email using SMTP config, or logs to console as a fallback.
13
+ """
14
+ subject = f"[QuantIQ Alert] Price Target Reached for {ticker.upper()}"
15
+
16
+ # HTML Content
17
+ html_content = f"""
18
+ <html>
19
+ <body style="font-family: Arial, sans-serif; background-color: #06070d; color: #f1f5f9; padding: 24px;">
20
+ <div style="max-width: 600px; margin: 0 auto; background-color: #0d101b; border: 1px solid rgba(255,255,255,0.07); border-radius: 12px; padding: 32px; box-sizing: border-box;">
21
+ <h2 style="color: #00f2fe; margin-top: 0; font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.07); padding-bottom: 16px;">
22
+ QuantIQ Alert Triggered
23
+ </h2>
24
+
25
+ <p style="font-size: 16px; line-height: 150%;">
26
+ Dear Valued Trader,
27
+ </p>
28
+
29
+ <p style="font-size: 15px; line-height: 150%;">
30
+ We are pleased to inform you that your price alert for <strong>{ticker.upper()}</strong> has been triggered!
31
+ </p>
32
+
33
+ <div style="background: rgba(0, 242, 254, 0.05); border-left: 4px solid #00f2fe; padding: 16px; margin: 24px 0; border-radius: 0 8px 8px 0;">
34
+ <table style="width: 100%; font-size: 14px; color: #94a3b8; border-collapse: collapse;">
35
+ <tr>
36
+ <td style="padding: 6px 0; font-weight: bold; color: #f1f5f9; width: 140px;">Asset Ticker:</td>
37
+ <td style="padding: 6px 0; color: #00f2fe; font-weight: bold;">{ticker.upper()}</td>
38
+ </tr>
39
+ <tr>
40
+ <td style="padding: 6px 0; font-weight: bold; color: #f1f5f9;">Your Target Price:</td>
41
+ <td style="padding: 6px 0; color: #f1f5f9;">${target_price:.2f} ({condition.upper()})</td>
42
+ </tr>
43
+ <tr>
44
+ <td style="padding: 6px 0; font-weight: bold; color: #f1f5f9;">Trigger Price:</td>
45
+ <td style="padding: 6px 0; color: #00e676; font-weight: bold;">${current_price:.2f}</td>
46
+ </tr>
47
+ </table>
48
+ </div>
49
+
50
+ <p style="font-size: 13px; line-height: 150%; color: #ef4444; border-top: 1px dashed rgba(255,255,255,0.07); padding-top: 16px;">
51
+ <strong>⚠️ Capital Risk Alert:</strong> To keep your investments secure, we will notify you every 2 minutes. Please log into your QuantIQ dashboard to deactivate or delete this alert and stop future email reminders.
52
+ </p>
53
+
54
+ <p style="font-size: 12px; color: #475569; margin-top: 32px; text-align: center;">
55
+ &copy; {datetime.datetime.now().year} QuantIQ. All rights reserved.
56
+ </p>
57
+ </div>
58
+ </body>
59
+ </html>
60
+ """
61
+
62
+ # Plain text fallback
63
+ text_content = (
64
+ f"Dear Valued Trader,\n\n"
65
+ f"Your price alert for {ticker.upper()} has been triggered!\n\n"
66
+ f"Asset: {ticker.upper()}\n"
67
+ f"Target Price: ${target_price:.2f} ({condition.upper()})\n"
68
+ f"Trigger Price: ${current_price:.2f}\n\n"
69
+ f"Risk Alert: To keep your investments secure, we will notify you every 2 minutes. "
70
+ f"Log into your QuantIQ dashboard to deactivate or delete this alert and stop future email reminders.\n\n"
71
+ f"Best regards,\n"
72
+ f"QuantIQ Team"
73
+ )
74
+
75
+ # Logging to console as development/fallback visualization
76
+ logger.info(f"\n========================================\n"
77
+ f"[EMAIL CONSOLE LOG] Dispatching to: {to_email}\n"
78
+ f"Subject: {subject}\n"
79
+ f"----------------------------------------\n"
80
+ f"{text_content}\n"
81
+ f"========================================")
82
+
83
+ # Check if SMTP configuration is available
84
+ if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
85
+ logger.warning("SMTP settings not configured. E-mail simulated via logs above.")
86
+ return True
87
+
88
+ try:
89
+ msg = MIMEMultipart("alternative")
90
+ msg["Subject"] = subject
91
+ msg["From"] = settings.SMTP_FROM
92
+ msg["To"] = to_email
93
+
94
+ msg.attach(MIMEText(text_content, "plain"))
95
+ msg.attach(MIMEText(html_content, "html"))
96
+
97
+ # Connect and send
98
+ with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
99
+ server.starttls()
100
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
101
+ server.sendmail(settings.SMTP_FROM, [to_email], msg.as_string())
102
+ logger.info(f"Successfully sent price alert email to {to_email}")
103
+ return True
104
+ except Exception as e:
105
+ logger.error(f"Failed to send email via SMTP host {settings.SMTP_HOST}: {e}")
106
+ return False