Skip to content

Commit a12da55

Browse files
committed
🚀 v1.3.0 — Earnings Calendar, Screener, Fast Dashboard Ledger, Alert Notes/Actionable, Cache Warmer, Bug Fixes
1 parent 27dfcc3 commit a12da55

37 files changed

Lines changed: 2671 additions & 237 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Notes and Actionable Alerts
2+
3+
Revision ID: 0004_add_notes_and_actionable_to_alert
4+
Revises: 0003_user_dark
5+
Create Date: 2026-05-31 21:31:26.248454
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '0004_add_notes_and_actionable_to_alert'
15+
down_revision = '0003_user_dark'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
with op.batch_alter_table('alert', schema=None) as batch_op:
23+
batch_op.add_column(sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True))
24+
batch_op.add_column(sa.Column('actionable', sa.Boolean(), nullable=False, server_default=sa.false()))
25+
26+
# ### end Alembic commands ###
27+
28+
29+
def downgrade():
30+
# ### commands auto generated by Alembic - please adjust! ###
31+
with op.batch_alter_table('alert', schema=None) as batch_op:
32+
batch_op.drop_column('actionable')
33+
batch_op.drop_column('notes')
34+
35+
# ### end Alembic commands ###
36+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Earnings Notify toggle
2+
3+
Revision ID: 0005_add_earnings_notify_to_user
4+
Revises: 0004_add_notes_and_actionable_to_alert
5+
Create Date: 2026-05-31 21:48:50.356325
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '0005_add_earnings_notify_to_user'
15+
down_revision = '0004_add_notes_and_actionable_to_alert'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
with op.batch_alter_table('user', schema=None) as batch_op:
23+
batch_op.add_column(sa.Column('earnings_notify', sa.Boolean(), nullable=True))
24+
25+
# ### end Alembic commands ###
26+
27+
28+
def downgrade():
29+
# ### commands auto generated by Alembic - please adjust! ###
30+
with op.batch_alter_table('user', schema=None) as batch_op:
31+
batch_op.drop_column('earnings_notify')
32+
33+
# ### end Alembic commands ###
34+

backend/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Settings(BaseSettings):
4242
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
4343
REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 h
4444
REGISTER_ENABLE: bool = True
45+
DEFAULT_CURRENCY: str = "€"
4546

4647

4748
@lru_cache

backend/main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717

1818
from config import get_settings
1919
from db.core import init_and_migrate_db
20-
from routers import alerts, auth, news, profile, projection, stock
21-
from services.scheduler import check_prices_and_notify
20+
from routers import alerts, auth, earnings, news, profile, projection, screener, stock
21+
from services.scheduler import check_prices_and_notify, notify_earnings_summary, warm_dashboard_cache
2222

23-
VERSION = "1.1.0"
23+
VERSION = "1.3.0"
2424

2525
logging.basicConfig(level=logging.INFO)
2626
log = logging.getLogger(__name__)
@@ -41,8 +41,10 @@ async def lifespan(app: FastAPI):
4141
log.info("STONKS ready")
4242
scheduler = AsyncIOScheduler()
4343
scheduler.add_job(check_prices_and_notify, 'cron', day_of_week='mon-fri', hour='7-22', minute='*/15')
44+
scheduler.add_job(notify_earnings_summary, 'cron', day_of_week='mon-fri', hour=8, minute=0)
45+
scheduler.add_job(warm_dashboard_cache, 'cron', day_of_week='mon-fri', hour='7-22', minute='*/45')
4446
scheduler.start()
45-
log.info("Scheduler (alerts) ready")
47+
log.info("Scheduler (alerts + earnings notifier) ready")
4648
yield
4749
scheduler.shutdown()
4850
log.info("Shutting down")
@@ -64,6 +66,8 @@ async def lifespan(app: FastAPI):
6466
app.include_router(profile.router, prefix="/api")
6567
app.include_router(alerts.router, prefix="/api")
6668
app.include_router(projection.router, prefix="/api")
69+
app.include_router(earnings.router, prefix="/api")
70+
app.include_router(screener.router, prefix="/api")
6771

6872

6973
@app.get("/api/")

backend/models/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class User(SQLModel, table=True):
5353
currency: str | None
5454
apprise_url: str | None = Field(default=None, description="Comma-separated Apprise URLs")
5555
dark_mode: bool | None
56+
earnings_notify: bool | None = Field(default=True)
5657

5758
watchlist_items: list["WatchlistItem"] = Relationship(
5859
back_populates="owner", cascade_delete=True
@@ -80,6 +81,8 @@ class Alert(SQLModel, table=True):
8081
trigger_above: bool
8182
is_armed: bool = Field(default=True)
8283
last_triggered: date | None = Field(default=None)
84+
notes: str | None = Field(default=None, max_length=500)
85+
actionable: bool = Field(default=False)
8386

8487
owner: User | None = Relationship(back_populates="alerts")
8588

backend/models/schemas.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class LoginRegisterModel(BaseModel):
4545
str,
4646
StringConstraints(min_length=1, max_length=19, pattern=r"^[a-zA-Z0-9_-]+$"),
4747
]
48-
password: str
48+
password: Annotated[str, StringConstraints(min_length=4)]
4949

5050

5151
class AuthParams(BaseModel):
@@ -111,6 +111,7 @@ class UserSettingsRequest(BaseModel):
111111
currency: str | None = Field(default=None, min_length=1, max_length=10, description="Currency symbol, e.g. €, $, £")
112112
apprise_url: str | None = Field(default=None, description="Comma-separated Apprise notification URLs")
113113
dark_mode: bool | None = Field(default=None)
114+
earnings_notify: bool | None = Field(default=None)
114115

115116

116117
class UserSettingsOut(BaseModel):
@@ -119,6 +120,7 @@ class UserSettingsOut(BaseModel):
119120
currency: str | None
120121
apprise_url: str | None
121122
dark_mode: bool | None
123+
earnings_notify: bool | None
122124

123125

124126
# =============================================================================
@@ -130,21 +132,34 @@ class AlertRequest(BaseModel):
130132
ticker: str = Field(min_length=1, max_length=20)
131133
target_price: float = Field(gt=0)
132134
trigger_above: bool
135+
notes: str | None = Field(default=None, max_length=500)
136+
actionable: bool = False
133137

134138
@field_validator("ticker")
135139
@classmethod
136140
def normalize_ticker(cls, v: str) -> str:
137141
return v.strip().upper()
138142

143+
@field_validator("notes")
144+
@classmethod
145+
def normalize_notes(cls, v: str | None) -> str | None:
146+
if v is not None:
147+
stripped = v.strip()
148+
return stripped if stripped else None
149+
return None
150+
139151

140152
class AlertUpdateRequest(BaseModel):
141153
target_price: float | None = Field(default=None, gt=0)
142154
trigger_above: bool | None = None
155+
notes: str | None = None # None = don't change; "" = clear; non-empty = set
156+
actionable: bool | None = None
143157

144158
@model_validator(mode="after")
145159
def at_least_one_field(self) -> "AlertUpdateRequest":
146-
if self.target_price is None and self.trigger_above is None:
147-
raise ValueError("Provide at least one of: target_price, trigger_above")
160+
if (self.target_price is None and self.trigger_above is None
161+
and self.notes is None and self.actionable is None):
162+
raise ValueError("Provide at least one field to update")
148163
return self
149164

150165

@@ -157,6 +172,8 @@ class AlertOut(BaseModel):
157172
trigger_above: bool
158173
is_armed: bool
159174
last_triggered: date | None
175+
notes: str | None
176+
actionable: bool
160177

161178

162179
# =============================================================================
@@ -512,6 +529,36 @@ class DashboardResponse(BaseModel):
512529
user_currency: str
513530

514531

532+
class RawPositionRow(BaseModel):
533+
"""Position computed from WAC only — no live price fields."""
534+
ticker: str
535+
envelope_name: str
536+
shares: float
537+
avg_cost: float
538+
cost_basis: float
539+
540+
541+
class StaticTotals(BaseModel):
542+
"""Totals computable from DB alone — no live prices required."""
543+
total_cash: float
544+
net_deposits: dict[str, float]
545+
dividend_income_90d: float
546+
547+
548+
class DashboardLedgerResponse(BaseModel):
549+
"""
550+
Fast DB+CPU slice of the dashboard. Returned by GET /profile/dashboard/ledger
551+
in ~20 ms. Contains everything that does not require a yfinance call so the
552+
frontend can render envelopes, the transaction ledger, and position skeletons
553+
while the full /dashboard market-data call is still in flight.
554+
"""
555+
envelopes: list[EnvelopeSummary] # total_value = cash_available only (no equity yet)
556+
transactions: list[TransactionOut]
557+
user_currency: str
558+
raw_positions: list[RawPositionRow]
559+
static_totals: StaticTotals
560+
561+
515562
# =============================================================================
516563
# PORTFOLIO OVERVIEW
517564
# =============================================================================
@@ -588,6 +635,7 @@ class ProjectionRequest(BaseModel):
588635
deposit: float = Field(ge=0.0, default=0.0)
589636
annual_rate_pct: float = Field(ge=0.0, le=100.0)
590637
deposit_frequency: DepositFrequency = DepositFrequency.MONTHLY
638+
initial_balance: float | None = Field(default=None, ge=0.0)
591639

592640

593641
class ProjectionDataset(BaseModel):
@@ -626,3 +674,60 @@ class ProjectionResponse(BaseModel):
626674
milestones: list[ProjectionMilestone]
627675
summary: ProjectionSummary
628676
inputs: ProjectionInputs
677+
678+
679+
# =============================================================================
680+
# EARNINGS CALENDAR
681+
# =============================================================================
682+
683+
684+
class EarningsEntry(BaseModel):
685+
ticker: str
686+
company_name: str | None
687+
earnings_date: str
688+
sector: str | None
689+
currency: str | None
690+
history: list[PricePoint]
691+
692+
693+
class EarningsCalendarResponse(BaseModel):
694+
entries: list[EarningsEntry]
695+
generated_at: str
696+
697+
698+
# =============================================================================
699+
# SCREENER
700+
# =============================================================================
701+
702+
703+
class ScreenerRow(BaseModel):
704+
ticker: str
705+
name: str
706+
sector: str | None
707+
currency: str
708+
current_price: float
709+
change_1d_pct: float
710+
change_5d_pct: float | None
711+
change_1m_pct: float | None
712+
rsi_14: float | None
713+
bollinger_b: float | None
714+
bollinger_signal: str | None
715+
sma_50: float | None
716+
sma_200: float | None
717+
sma_signal: str | None
718+
macd_signal: str | None
719+
stochastic_k: float | None
720+
stochastic_signal: str | None
721+
volume_trend_ratio: float | None
722+
volume_trend_signal: str | None
723+
buy_pct: int
724+
hold_pct: int
725+
sell_pct: int
726+
is_held: bool
727+
728+
729+
class ScreenerSentiment(BaseModel):
730+
ticker: str
731+
sentiment_score: float # -1.0 to 1.0 (winsorized)
732+
label: str # positive | neutral | negative
733+
article_count: int

backend/routers/alerts.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def create_alert(
5252
ticker=req.ticker,
5353
target_price=req.target_price,
5454
trigger_above=req.trigger_above,
55+
notes=req.notes,
56+
actionable=req.actionable,
5557
)
5658
session.add(alert)
5759
session.commit()
@@ -80,10 +82,15 @@ def update_alert(
8082
alert.target_price = req.target_price
8183
if req.trigger_above is not None:
8284
alert.trigger_above = req.trigger_above
83-
84-
# Changing the trigger condition resets the alert so it can fire on the new settings.
85-
alert.is_armed = True
86-
alert.last_triggered = None
85+
if req.notes is not None:
86+
alert.notes = req.notes.strip() or None
87+
if req.actionable is not None:
88+
alert.actionable = req.actionable
89+
90+
# Only reset armed state when the trigger condition itself changes.
91+
if req.target_price is not None or req.trigger_above is not None:
92+
alert.is_armed = True
93+
alert.last_triggered = None
8794

8895
session.add(alert)
8996
session.commit()

backend/routers/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from deps import SessionDep
2020
from models.models import User
2121
from models.schemas import AuthParams, LoginRegisterModel, Token
22-
from security import create_access_token, create_tokens, hash_password, verify_password
22+
from security import create_access_token, create_refresh_token, create_tokens, hash_password, verify_password
2323

2424
router = APIRouter(prefix="/auth", tags=["auth"])
2525

@@ -42,7 +42,7 @@ def register(req: LoginRegisterModel, session: SessionDep) -> Token:
4242
if not get_settings().REGISTER_ENABLE:
4343
raise HTTPException(status_code=403, detail="Registration is disabled")
4444
if session.get(User, req.username):
45-
raise HTTPException(status_code=409, detail="Username already taken")
45+
raise HTTPException(status_code=409, detail="Registration failed")
4646
session.add(User(username=req.username, hashed_password=hash_password(req.password)))
4747
session.commit()
4848
return create_tokens(data={"sub": req.username})
@@ -81,5 +81,5 @@ def refresh_token(
8181

8282
return Token(
8383
access_token=create_access_token(data={"sub": username}),
84-
refresh_token=token, # reuse existing refresh token until it expires
84+
refresh_token=create_refresh_token(data={"sub": username}),
8585
)

backend/routers/earnings.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Earnings Calendar router
3+
========================
4+
GET /api/earnings → EarningsCalendarResponse for ±1 week from today
5+
"""
6+
7+
from typing import Annotated
8+
9+
from fastapi import APIRouter, Depends
10+
from starlette.concurrency import run_in_threadpool
11+
12+
from deps import get_current_username
13+
from models.schemas import EarningsCalendarResponse
14+
from services.earnings_service import get_earnings_calendar
15+
16+
router = APIRouter(prefix="/earnings", tags=["Earnings"])
17+
18+
19+
@router.get("", summary="Earnings calendar for ±1 week from today", response_model=EarningsCalendarResponse)
20+
async def earnings_calendar(current_user: Annotated[str, Depends(get_current_username)]) -> dict:
21+
return await run_in_threadpool(get_earnings_calendar)

0 commit comments

Comments
 (0)