From c8277e301ed72a361d3c01a6768858d2d8954e3e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:19:00 +0000 Subject: [PATCH] feat: pydantic schemas for all service message types - shared/schemas/trading.py: OrderRequest, OrderResult, PositionInfo, AccountInfo, TradeSignal, TradeExecution, MarketSnapshot, SentimentContext - shared/schemas/news.py: RawArticle, ScoredArticle - shared/schemas/learning.py: TradeOutcomeSchema, WeightAdjustment - shared/schemas/auth.py: RegisterRequest, LoginRequest, TokenResponse - 49 schema tests covering validation constraints, serialization round-trips, required fields, and range checks --- shared/schemas/__init__.py | 37 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 884 bytes .../schemas/__pycache__/auth.cpython-314.pyc | Bin 0 -> 2271 bytes .../__pycache__/learning.cpython-314.pyc | Bin 0 -> 1978 bytes .../schemas/__pycache__/news.cpython-314.pyc | Bin 0 -> 2388 bytes .../__pycache__/trading.cpython-314.pyc | Bin 0 -> 8691 bytes shared/schemas/auth.py | 27 + shared/schemas/learning.py | 32 + shared/schemas/news.py | 44 ++ shared/schemas/trading.py | 163 +++++ tests/test_schemas.py | 586 ++++++++++++++++++ 11 files changed, 889 insertions(+) create mode 100644 shared/schemas/__init__.py create mode 100644 shared/schemas/__pycache__/__init__.cpython-314.pyc create mode 100644 shared/schemas/__pycache__/auth.cpython-314.pyc create mode 100644 shared/schemas/__pycache__/learning.cpython-314.pyc create mode 100644 shared/schemas/__pycache__/news.cpython-314.pyc create mode 100644 shared/schemas/__pycache__/trading.cpython-314.pyc create mode 100644 shared/schemas/auth.py create mode 100644 shared/schemas/learning.py create mode 100644 shared/schemas/news.py create mode 100644 shared/schemas/trading.py create mode 100644 tests/test_schemas.py diff --git a/shared/schemas/__init__.py b/shared/schemas/__init__.py new file mode 100644 index 0000000..b6583bf --- /dev/null +++ b/shared/schemas/__init__.py @@ -0,0 +1,37 @@ +"""Pydantic v2 schemas for all service message types.""" + +from shared.schemas.trading import ( + AccountInfo, + MarketSnapshot, + OrderRequest, + OrderResult, + PositionInfo, + SentimentContext, + TradeExecution, + TradeSignal, +) +from shared.schemas.news import RawArticle, ScoredArticle +from shared.schemas.learning import TradeOutcomeSchema, WeightAdjustment +from shared.schemas.auth import LoginRequest, RegisterRequest, TokenResponse + +__all__ = [ + # Trading + "OrderRequest", + "OrderResult", + "PositionInfo", + "AccountInfo", + "TradeSignal", + "TradeExecution", + "MarketSnapshot", + "SentimentContext", + # News + "RawArticle", + "ScoredArticle", + # Learning + "TradeOutcomeSchema", + "WeightAdjustment", + # Auth + "RegisterRequest", + "LoginRequest", + "TokenResponse", +] diff --git a/shared/schemas/__pycache__/__init__.cpython-314.pyc b/shared/schemas/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e710f89b053f2c1804da38f261e31175e7007cb GIT binary patch literal 884 zcmZXS%Wl*#6o#G5y_s7tT+{`P#A*bcpbDXa5K@Ie9;whOQ^bzoCZ{uDGWKAf^tM90 z22a4_u*91#cmYHg-D0OxDiurq9qbgk zJm}IzT%=35M3-@Sjr2)a-EnXwtlTHP+UdsbNy0d$v46PXt9T4kru+jTeU@du0(qFm z;7>s*HUb|{W}y1Ly7so>SmYcB{6OgT8zv`!L(XPuEKoOhWCAjT&p9a6-dw3XGof9f zQcMLe4t9rNDo+hw3XX7$y1ge^0J}kwkwC-kC?Cg2QxzLhdtpn(`nP2?TTh>-z$Oew?w~d^-|yf)YRl=Jjw~X9tfr z_n$s_6kM7M=64UGDCH?eQGa%#>(8em$uoE%S4~03O!d&Y2ad1&j-VoTL|sC<_xKML)8ZI7X4OSjG&Oxm?b*o*g;ZNVS6 zxKtIvWitvTx0|dTM9h=3Wj!9c!zBMK@^}zuKJ|Ivx&2)5dzjA>7z{F6BT9p;^mR)a z6C{G%l)08UN7l7<&(W_zJ#Z+?F;w;;nbRD|DafgulTkwtiOM?$#BRkS4&U&l;=)N| z+nRp>nb_5~4Jp|kll5xEglDVBuENR?LY8uqw;>nYCz6b3xAi#R@IxB#a6{EWXHRq~9(Yfa`&wPAkO$hAb9sN=k)ZTy@ z17{$5^DoRY&b1^LA#3o}xZwG+8L&1*b6blIh!9F^K?530#YIZp0OI$2S5Yc)_lKSM zQRNFbKvur;H&rC6Olj2gWr8Qqj z75E_!BXDFX^&*#2f!z?pC<-9r(Sdkstq@kk<4`Gx-ghZeO8C{5;u74j^01aJ4+2WJ zHSr#tA+g!F#VDSMs7*wYnhh4D)*-v*t=~&iKi&U%?$^?_&eHRf;XNT4EzX!aIeHQM-a4!2uce(4s)yJyqJKm zm_+eDiYXMQP`t*&G@7STe1PH%h{4v{=tPm+iHm!!y*=34S9o>5tGN2@UHuoSjlJ%w zqpQrC1ypS~rjt1&wv^O>k`q$P_DC%u&o)rzeaDx(4iLq8~7mNiY)^SQ05sB8`iJ*Wll!-ZS%wm#TJ? z$9evw>BAiLLx9%_I3zawP`w*!r;b$e2-e2n1bG8QA8TV1JLY(wtxA(S=0q3YbT>~% zOFQP7?l2k!FuFv~SbN1cKso&1pu7mTq{zI4+W#Q)Jgz~JM0f#|9x~@%+lNiG~%rgr|Be?Z`KaKg|Qo?Sm6@Hr^tH0#;&O9G$dz<&TH Cq8Xh4 literal 0 HcmV?d00001 diff --git a/shared/schemas/__pycache__/learning.cpython-314.pyc b/shared/schemas/__pycache__/learning.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba2926805ce0cbac0ad7e4fd0dc9d85b6c9159d2 GIT binary patch literal 1978 zcmbW2&u<$=6vt=n_0Pol(Zu;dlV;P>Ru;rYw1S`()S?O{r4-mU;eeW9ygRm+u6NAL zxKVOS0f{R|BrY5{(Ep}6p^n4}A;c|iBH+k-yX)eDxUh$}@6F84y!Yn&v0s{~lnFei z&wLyFm?7k642DlGb&hwyIUp|4WQVxcrnO;dYX$wxW@aO+v$3Duv^R1(w~^QR6;dUu z#I@&%o4c30oK5!8g?Ka%qlJ;tqFX#iYNh?vTb#)#h}w=Xx-5vC5B7W(>A-W8*Wq2J z>NU$0eWtk%x*U9aYisjHEn`Y=GsWK%J`YXqoq&hFH!J|(b{ZVt1cyNx_lUNjl*~>^ zXXl83g0y`vw;Gr8x|OWfc{l6Y4NDic$tBCp&6BXGi*8<*wuvsg1xUDPPTiHv=kNEm zC%XJjoa6o_m-i%BJkrY19q#Z4EbK$dz9W)#j%Yc|@j{_sOydSvR;JC(WJ`)JWm?Oi z+1Fg%^)Q(E)R3xj>`(_R#sf)oMm zdD=|uGDUk*v;xhVA+Kd6CTw!8P%v%mrVt_AR=y=<7vc(~UdWX415Z<`@Z_@x=99*j z;!-sp1oyR&O(yq)sNuogHZUm=$68b9MjgKF!`QBn_qF6)HCP*3zQ$UY*PCy=_DVx_ znB;yV6;2~el~wQUnQ=;46bTKNLaAB#_Z`+p7nC3^bpyt*c zV|($aOsW^Y(~oCfKDhPA43zl=kqg5wJ#d~qc*po0uY-9G#p>TEx*0bM;N@cQ;1>kI z%ey%Q5dj317R=N~JZN|Hb^p`8(p|Xt{c9uGF-L(`G;i-YyYV{5>#(TJoi4!UH1}dI zhl+4`)Q0xfC6@Rf#6>XmKZrxJhQy&iBo3*Ji337$JfD)71;3DzSd?RMs2Lkpf-qK| z1*i{GMl_QGO*!JbLq892DiN8A7 zpdV`>fiV%m944~-&yGI_35rEgFhfXA%^lj+Atkf(hxXi%lIeIRr6dVbOe#|k-yI+& zgDEnL$PA`2I7OxqtfwffL{^J%(Cu1Dz5s7|8qE@#Wi)5dte|nwtfD!K<{X$&X`jbI zG;!{@ODgS~N3$bxo*$7juEulYGq6L(?)L*fX=bsP{#i*IW)eVSeBy&JaoMU zcN!&PF##l2NU%UcVpEAtyW=0QWl5xN>J|3?j%~Vn|EUq9mkQ#PX87m=T#pMJTgmjGQ1w zW}FzxO6Ec)Su2L(=nzD+1JSIJJ4I^h_QEpVxIU_67J4ECS8BUxFHN)C`D>?6W z<7Riwb9tLOx(M{uRUeXhjai$ktNPlOF4|Pbh~A+ESC;l0G1>%Z79Y=A>92z?lK8q!>~; zpM&Y?1FN(B5x}{D6)Iv#Gyex(S(xQPm)SJTbQ#>MaD_+1oE`Xr`eHdOZ1l5-j6yBR z(m`BE=a_Aax1l;2E*Qfd0cWA69hg%zP200LcU)VTCdbi}z3{{4`CDxbR$``T-2*Ow$Q$(_|yCO;~_s)HC7c zTkd*T9!RLw^x2Dc2EyY@i;K^SHp)WN2nXPS>BR)HNkw&j~_6fDd3O?)>e!GpW$ zktfQ+UV+Tjde`?;h7aU~0CGK`G8 zgv|ku$i=akr3Wz;V=H+hdnu)n(2Sg+0*i-Z7UyFY7Yt1lfyE;+iwj}#mK{Kc^ohLl zZ$#$tl;>RL&|;uAzR)e-^0u~t(VDj81G8NL8s>R=3zY}>4kXu~HBzme-RU_uc@G$m=weJ(`AqHd0t}Xv^w)!faVKFk_1$q5nfZ zeuSa_Pd-vn%BQj-gT!b9csRu5)p}}(b1EwPar-?s?)p5d~^z()1(YiQ;+7*IYY{$J2#>i5T7Nb z%2)GWxVy^5r!@#9Or3zk_M7mBT8T}9&#)$J8qF~@GiXq57=EkRi)hZFX`-p4IgVx) zjgIC78WfB{IXR6%H1RE61rWSAy4=h3;o%e7o`*tnwVM*72o;pa6udlpfA5KKI|0@S)t zy_^W}74|Z?C@LIVaW5lD(i3v!H!|}(nR}KUmg-+lJ|pmYcB~3`=}!W$c%Od&b;&;6 literal 0 HcmV?d00001 diff --git a/shared/schemas/__pycache__/trading.cpython-314.pyc b/shared/schemas/__pycache__/trading.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..030c4e077bcf6a356069179744f71e4d93cf982c GIT binary patch literal 8691 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 literal 0 HcmV?d00001 diff --git a/shared/schemas/auth.py b/shared/schemas/auth.py new file mode 100644 index 0000000..2dadacd --- /dev/null +++ b/shared/schemas/auth.py @@ -0,0 +1,27 @@ +"""Authentication Pydantic schemas for API request/response payloads.""" + +from pydantic import BaseModel, Field + + +class RegisterRequest(BaseModel): + """Sent by the dashboard to begin passkey registration.""" + + username: str = Field(min_length=1, max_length=100) + display_name: str | None = None + + +class LoginRequest(BaseModel): + """Sent by the dashboard to begin passkey authentication.""" + + username: str = Field(min_length=1, max_length=100) + + +class TokenResponse(BaseModel): + """Returned after successful authentication.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = Field( + description="Access token lifetime in seconds", default=900 + ) diff --git a/shared/schemas/learning.py b/shared/schemas/learning.py new file mode 100644 index 0000000..cb8bd41 --- /dev/null +++ b/shared/schemas/learning.py @@ -0,0 +1,32 @@ +"""Learning domain Pydantic schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class TradeOutcomeSchema(BaseModel): + """Represents the evaluated outcome of a closed trade.""" + + trade_id: UUID + hold_duration_seconds: float = Field(ge=0) + realized_pnl: float + roi_pct: float + was_profitable: bool + + model_config = {"from_attributes": True} + + +class WeightAdjustment(BaseModel): + """Represents a strategy weight change made by the learning engine.""" + + strategy_id: UUID + strategy_name: str + old_weight: float + new_weight: float + reason: str + reward_signal: float + timestamp: datetime + + model_config = {"from_attributes": True} diff --git a/shared/schemas/news.py b/shared/schemas/news.py new file mode 100644 index 0000000..e23adb1 --- /dev/null +++ b/shared/schemas/news.py @@ -0,0 +1,44 @@ +"""News article Pydantic schemas for Redis Stream messages.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RawArticle(BaseModel): + """Published to ``news:raw`` by the news fetcher.""" + + source: str + url: str + title: str + content: str + published_at: datetime | None = None + fetched_at: datetime + content_hash: str + + model_config = {"from_attributes": True} + + +class ScoredArticle(BaseModel): + """Published to ``news:scored`` by the sentiment analyzer. + + Inherits all fields from RawArticle conceptually plus scoring metadata. + """ + + # Original article fields + source: str + url: str + title: str + content: str + published_at: datetime | None = None + fetched_at: datetime + content_hash: str + + # Scoring fields + ticker: str + sentiment_score: float = Field(ge=-1.0, le=1.0) + confidence: float = Field(ge=0.0, le=1.0) + model_used: str + entities: list[str] = Field(default_factory=list) + + model_config = {"from_attributes": True} diff --git a/shared/schemas/trading.py b/shared/schemas/trading.py new file mode 100644 index 0000000..0a0046b --- /dev/null +++ b/shared/schemas/trading.py @@ -0,0 +1,163 @@ +"""Trading-related Pydantic schemas for Redis Streams messages and API payloads.""" + +from datetime import datetime +from enum import Enum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +class OrderType(str, Enum): + MARKET = "market" + LIMIT = "limit" + STOP = "stop" + + +class OrderSide(str, Enum): + BUY = "BUY" + SELL = "SELL" + + +class OrderStatus(str, Enum): + PENDING = "PENDING" + FILLED = "FILLED" + CANCELLED = "CANCELLED" + REJECTED = "REJECTED" + + +class SignalDirection(str, Enum): + LONG = "LONG" + SHORT = "SHORT" + NEUTRAL = "NEUTRAL" + + +# --------------------------------------------------------------------------- +# API request / response schemas +# --------------------------------------------------------------------------- + + +class OrderRequest(BaseModel): + """Submitted by the trade executor or the API to place an order.""" + + ticker: str + side: OrderSide + qty: float = Field(gt=0) + order_type: OrderType = OrderType.MARKET + limit_price: float | None = None + stop_price: float | None = None + + model_config = {"from_attributes": True} + + +class OrderResult(BaseModel): + """Returned after order submission or status query.""" + + order_id: str + ticker: str + side: OrderSide + qty: float + filled_price: float | None = None + status: OrderStatus + timestamp: datetime + + model_config = {"from_attributes": True} + + +class PositionInfo(BaseModel): + """Current position state — used in API responses and portfolio views.""" + + ticker: str + qty: float + avg_entry: float + current_price: float + unrealized_pnl: float + market_value: float + + model_config = {"from_attributes": True} + + +class AccountInfo(BaseModel): + """Account-level summary from the brokerage.""" + + equity: float + cash: float + buying_power: float + portfolio_value: float + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# Redis Stream message schemas +# --------------------------------------------------------------------------- + + +class TradeSignal(BaseModel): + """Published to ``signals:generated`` by the signal generator.""" + + ticker: str + direction: SignalDirection + strength: float = Field(ge=0.0, le=1.0) + strategy_sources: list[str] + sentiment_context: dict[str, Any] | None = None + timestamp: datetime + + model_config = {"from_attributes": True} + + +class TradeExecution(BaseModel): + """Published to ``trades:executed`` by the trade executor.""" + + trade_id: UUID + ticker: str + side: OrderSide + qty: float + price: float + status: OrderStatus + signal_id: UUID | None = None + strategy_id: UUID | None = None + timestamp: datetime + + model_config = {"from_attributes": True} + + +class OHLCVBar(BaseModel): + """Single OHLCV bar.""" + + timestamp: datetime + open: float + high: float + low: float + close: float + volume: float + + +class MarketSnapshot(BaseModel): + """Snapshot of market data for a single ticker — used by strategies.""" + + ticker: str + current_price: float + open: float + high: float + low: float + close: float + volume: float + sma_20: float | None = None + sma_50: float | None = None + rsi: float | None = None + bars: list[dict[str, Any]] = Field(default_factory=list) + + model_config = {"from_attributes": True} + + +class SentimentContext(BaseModel): + """Aggregated sentiment for a ticker — passed to strategies.""" + + ticker: str + avg_score: float = Field(ge=-1.0, le=1.0) + article_count: int = Field(ge=0) + recent_scores: list[float] = Field(default_factory=list) + avg_confidence: float = Field(ge=0.0, le=1.0) + + model_config = {"from_attributes": True} diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..d44d248 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,586 @@ +"""Tests for Pydantic schemas — serialization round-trips and validation constraints.""" + +import uuid +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from shared.schemas.trading import ( + AccountInfo, + MarketSnapshot, + OrderRequest, + OrderResult, + OrderSide, + OrderStatus, + OrderType, + PositionInfo, + SentimentContext, + SignalDirection, + TradeExecution, + TradeSignal, +) +from shared.schemas.news import RawArticle, ScoredArticle +from shared.schemas.learning import TradeOutcomeSchema, WeightAdjustment +from shared.schemas.auth import LoginRequest, RegisterRequest, TokenResponse + + +# --------------------------------------------------------------------------- +# Trading schemas +# --------------------------------------------------------------------------- + + +class TestOrderRequest: + def test_valid_market_order(self) -> None: + o = OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=10.0) + assert o.order_type == OrderType.MARKET + assert o.limit_price is None + + def test_valid_limit_order(self) -> None: + o = OrderRequest( + ticker="TSLA", + side=OrderSide.SELL, + qty=5.0, + order_type=OrderType.LIMIT, + limit_price=250.50, + ) + assert o.limit_price == 250.50 + + def test_qty_must_be_positive(self) -> None: + with pytest.raises(ValidationError): + OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=0) + + def test_qty_must_not_be_negative(self) -> None: + with pytest.raises(ValidationError): + OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=-5) + + def test_serialization_round_trip(self) -> None: + o = OrderRequest( + ticker="GOOG", + side=OrderSide.BUY, + qty=3.0, + order_type=OrderType.STOP, + stop_price=100.0, + ) + data = o.model_dump() + restored = OrderRequest.model_validate(data) + assert restored == o + + def test_json_round_trip(self) -> None: + o = OrderRequest(ticker="META", side=OrderSide.SELL, qty=1.5) + json_str = o.model_dump_json() + restored = OrderRequest.model_validate_json(json_str) + assert restored == o + + +class TestOrderResult: + def test_valid_result(self) -> None: + now = datetime.now(timezone.utc) + r = OrderResult( + order_id="ord-123", + ticker="AAPL", + side=OrderSide.BUY, + qty=10.0, + filled_price=150.25, + status=OrderStatus.FILLED, + timestamp=now, + ) + assert r.filled_price == 150.25 + assert r.status == OrderStatus.FILLED + + def test_pending_no_fill(self) -> None: + now = datetime.now(timezone.utc) + r = OrderResult( + order_id="ord-456", + ticker="TSLA", + side=OrderSide.SELL, + qty=5.0, + status=OrderStatus.PENDING, + timestamp=now, + ) + assert r.filled_price is None + + +class TestPositionInfo: + def test_valid_position(self) -> None: + p = PositionInfo( + ticker="NVDA", + qty=20.0, + avg_entry=800.0, + current_price=850.0, + unrealized_pnl=1000.0, + market_value=17000.0, + ) + assert p.market_value == 17000.0 + + def test_serialization_round_trip(self) -> None: + p = PositionInfo( + ticker="AMZN", + qty=10.0, + avg_entry=180.0, + current_price=185.0, + unrealized_pnl=50.0, + market_value=1850.0, + ) + restored = PositionInfo.model_validate(p.model_dump()) + assert restored == p + + +class TestAccountInfo: + def test_valid_account(self) -> None: + a = AccountInfo( + equity=100_000.0, + cash=25_000.0, + buying_power=50_000.0, + portfolio_value=100_000.0, + ) + assert a.equity == 100_000.0 + + +class TestTradeSignal: + def test_valid_signal(self) -> None: + now = datetime.now(timezone.utc) + s = TradeSignal( + ticker="AAPL", + direction=SignalDirection.LONG, + strength=0.85, + strategy_sources=["momentum", "news_driven"], + timestamp=now, + ) + assert s.strength == 0.85 + assert s.sentiment_context is None + + def test_strength_must_be_in_range(self) -> None: + now = datetime.now(timezone.utc) + with pytest.raises(ValidationError): + TradeSignal( + ticker="AAPL", + direction=SignalDirection.LONG, + strength=1.5, + strategy_sources=["momentum"], + timestamp=now, + ) + + def test_strength_lower_bound(self) -> None: + now = datetime.now(timezone.utc) + with pytest.raises(ValidationError): + TradeSignal( + ticker="AAPL", + direction=SignalDirection.SHORT, + strength=-0.1, + strategy_sources=["mean_reversion"], + timestamp=now, + ) + + def test_json_round_trip(self) -> None: + now = datetime.now(timezone.utc) + s = TradeSignal( + ticker="TSLA", + direction=SignalDirection.SHORT, + strength=0.6, + strategy_sources=["mean_reversion"], + sentiment_context={"avg_score": -0.4}, + timestamp=now, + ) + restored = TradeSignal.model_validate_json(s.model_dump_json()) + assert restored == s + + +class TestTradeExecution: + def test_valid_execution(self) -> None: + now = datetime.now(timezone.utc) + tid = uuid.uuid4() + sid = uuid.uuid4() + e = TradeExecution( + trade_id=tid, + ticker="AAPL", + side=OrderSide.BUY, + qty=10.0, + price=150.0, + status=OrderStatus.FILLED, + signal_id=sid, + strategy_id=uuid.uuid4(), + timestamp=now, + ) + assert e.trade_id == tid + assert e.signal_id == sid + + def test_optional_ids(self) -> None: + now = datetime.now(timezone.utc) + e = TradeExecution( + trade_id=uuid.uuid4(), + ticker="GOOG", + side=OrderSide.SELL, + qty=5.0, + price=2800.0, + status=OrderStatus.FILLED, + timestamp=now, + ) + assert e.signal_id is None + assert e.strategy_id is None + + +class TestMarketSnapshot: + def test_valid_snapshot(self) -> None: + ms = MarketSnapshot( + ticker="AAPL", + current_price=150.0, + open=148.0, + high=152.0, + low=147.0, + close=150.0, + volume=5_000_000.0, + sma_20=149.5, + rsi=55.0, + ) + assert ms.sma_50 is None + assert ms.bars == [] + + def test_with_bars(self) -> None: + ms = MarketSnapshot( + ticker="TSLA", + current_price=250.0, + open=248.0, + high=255.0, + low=245.0, + close=250.0, + volume=10_000_000.0, + bars=[ + {"timestamp": "2026-01-01T10:00:00Z", "open": 248, "close": 250} + ], + ) + assert len(ms.bars) == 1 + + +class TestSentimentContext: + def test_valid_context(self) -> None: + sc = SentimentContext( + ticker="AAPL", + avg_score=0.65, + article_count=5, + recent_scores=[0.5, 0.7, 0.8], + avg_confidence=0.85, + ) + assert sc.article_count == 5 + + def test_score_range_validation(self) -> None: + with pytest.raises(ValidationError): + SentimentContext( + ticker="AAPL", + avg_score=1.5, # Out of range + article_count=1, + avg_confidence=0.5, + ) + + def test_negative_score_in_range(self) -> None: + sc = SentimentContext( + ticker="TSLA", + avg_score=-0.8, + article_count=3, + avg_confidence=0.9, + ) + assert sc.avg_score == -0.8 + + def test_confidence_range_validation(self) -> None: + with pytest.raises(ValidationError): + SentimentContext( + ticker="AAPL", + avg_score=0.5, + article_count=1, + avg_confidence=1.5, # Out of range + ) + + def test_article_count_non_negative(self) -> None: + with pytest.raises(ValidationError): + SentimentContext( + ticker="AAPL", + avg_score=0.0, + article_count=-1, + avg_confidence=0.5, + ) + + +# --------------------------------------------------------------------------- +# News schemas +# --------------------------------------------------------------------------- + + +class TestRawArticle: + def test_valid_article(self) -> None: + now = datetime.now(timezone.utc) + a = RawArticle( + source="reuters", + url="https://reuters.com/article/1", + title="Market Rally Continues", + content="Stocks rose sharply today...", + published_at=now, + fetched_at=now, + content_hash="sha256abcdef1234567890", + ) + assert a.source == "reuters" + + def test_published_at_optional(self) -> None: + now = datetime.now(timezone.utc) + a = RawArticle( + source="reddit", + url="https://reddit.com/r/stocks/1", + title="DD on TSLA", + content="Here is my analysis...", + fetched_at=now, + content_hash="hash123", + ) + assert a.published_at is None + + def test_json_round_trip(self) -> None: + now = datetime.now(timezone.utc) + a = RawArticle( + source="yahoo", + url="https://finance.yahoo.com/1", + title="Earnings Beat", + content="Apple beat earnings...", + published_at=now, + fetched_at=now, + content_hash="hash456", + ) + restored = RawArticle.model_validate_json(a.model_dump_json()) + assert restored == a + + def test_required_fields(self) -> None: + with pytest.raises(ValidationError): + RawArticle(source="reuters") # type: ignore[call-arg] + + +class TestScoredArticle: + def test_valid_scored_article(self) -> None: + now = datetime.now(timezone.utc) + sa = ScoredArticle( + source="reuters", + url="https://reuters.com/article/1", + title="Apple Earnings Beat", + content="Apple reported...", + published_at=now, + fetched_at=now, + content_hash="hash789", + ticker="AAPL", + sentiment_score=0.85, + confidence=0.92, + model_used="finbert", + entities=["Apple Inc", "Tim Cook"], + ) + assert sa.sentiment_score == 0.85 + assert sa.entities == ["Apple Inc", "Tim Cook"] + + def test_sentiment_score_range(self) -> None: + now = datetime.now(timezone.utc) + with pytest.raises(ValidationError): + ScoredArticle( + source="reuters", + url="https://reuters.com/1", + title="Test", + content="Test content", + fetched_at=now, + content_hash="hash", + ticker="AAPL", + sentiment_score=1.5, # Out of range + confidence=0.5, + model_used="finbert", + ) + + def test_confidence_range(self) -> None: + now = datetime.now(timezone.utc) + with pytest.raises(ValidationError): + ScoredArticle( + source="reuters", + url="https://reuters.com/1", + title="Test", + content="Test content", + fetched_at=now, + content_hash="hash", + ticker="AAPL", + sentiment_score=0.5, + confidence=-0.1, # Out of range + model_used="finbert", + ) + + def test_negative_sentiment(self) -> None: + now = datetime.now(timezone.utc) + sa = ScoredArticle( + source="reddit", + url="https://reddit.com/1", + title="Bad news", + content="Terrible quarter...", + fetched_at=now, + content_hash="hashN", + ticker="TSLA", + sentiment_score=-0.9, + confidence=0.8, + model_used="ollama", + ) + assert sa.sentiment_score == -0.9 + + def test_json_round_trip(self) -> None: + now = datetime.now(timezone.utc) + sa = ScoredArticle( + source="yahoo", + url="https://yahoo.com/1", + title="Headline", + content="Body text", + fetched_at=now, + content_hash="hashRT", + ticker="NVDA", + sentiment_score=0.3, + confidence=0.7, + model_used="finbert", + entities=["NVIDIA"], + ) + restored = ScoredArticle.model_validate_json(sa.model_dump_json()) + assert restored == sa + + +# --------------------------------------------------------------------------- +# Learning schemas +# --------------------------------------------------------------------------- + + +class TestTradeOutcomeSchema: + def test_valid_outcome(self) -> None: + o = TradeOutcomeSchema( + trade_id=uuid.uuid4(), + hold_duration_seconds=14400.0, + realized_pnl=250.50, + roi_pct=2.5, + was_profitable=True, + ) + assert o.was_profitable is True + assert o.hold_duration_seconds == 14400.0 + + def test_hold_duration_non_negative(self) -> None: + with pytest.raises(ValidationError): + TradeOutcomeSchema( + trade_id=uuid.uuid4(), + hold_duration_seconds=-1.0, + realized_pnl=100.0, + roi_pct=1.0, + was_profitable=True, + ) + + def test_losing_trade(self) -> None: + o = TradeOutcomeSchema( + trade_id=uuid.uuid4(), + hold_duration_seconds=3600.0, + realized_pnl=-150.0, + roi_pct=-3.0, + was_profitable=False, + ) + assert o.was_profitable is False + assert o.realized_pnl == -150.0 + + def test_json_round_trip(self) -> None: + o = TradeOutcomeSchema( + trade_id=uuid.uuid4(), + hold_duration_seconds=7200.0, + realized_pnl=500.0, + roi_pct=5.0, + was_profitable=True, + ) + restored = TradeOutcomeSchema.model_validate_json(o.model_dump_json()) + assert restored == o + + +class TestWeightAdjustment: + def test_valid_adjustment(self) -> None: + now = datetime.now(timezone.utc) + wa = WeightAdjustment( + strategy_id=uuid.uuid4(), + strategy_name="momentum", + old_weight=0.33, + new_weight=0.38, + reason="Positive reward signal from recent trades", + reward_signal=0.72, + timestamp=now, + ) + assert wa.old_weight == 0.33 + assert wa.new_weight == 0.38 + + def test_required_fields(self) -> None: + with pytest.raises(ValidationError): + WeightAdjustment( + strategy_id=uuid.uuid4(), + strategy_name="momentum", + ) # type: ignore[call-arg] + + def test_json_round_trip(self) -> None: + now = datetime.now(timezone.utc) + wa = WeightAdjustment( + strategy_id=uuid.uuid4(), + strategy_name="mean_reversion", + old_weight=0.30, + new_weight=0.25, + reason="Poor recent performance", + reward_signal=-0.4, + timestamp=now, + ) + restored = WeightAdjustment.model_validate_json(wa.model_dump_json()) + assert restored == wa + + +# --------------------------------------------------------------------------- +# Auth schemas +# --------------------------------------------------------------------------- + + +class TestRegisterRequest: + def test_valid_registration(self) -> None: + r = RegisterRequest(username="trader1", display_name="Top Trader") + assert r.username == "trader1" + assert r.display_name == "Top Trader" + + def test_display_name_optional(self) -> None: + r = RegisterRequest(username="trader2") + assert r.display_name is None + + def test_username_required(self) -> None: + with pytest.raises(ValidationError): + RegisterRequest(username="") # min_length=1 + + def test_username_max_length(self) -> None: + with pytest.raises(ValidationError): + RegisterRequest(username="x" * 101) # max_length=100 + + +class TestLoginRequest: + def test_valid_login(self) -> None: + l = LoginRequest(username="trader1") + assert l.username == "trader1" + + def test_username_required(self) -> None: + with pytest.raises(ValidationError): + LoginRequest(username="") + + +class TestTokenResponse: + def test_valid_response(self) -> None: + t = TokenResponse( + access_token="eyJ...", + refresh_token="eyR...", + ) + assert t.token_type == "bearer" + assert t.expires_in == 900 # default 15 min + + def test_custom_expiry(self) -> None: + t = TokenResponse( + access_token="eyJ...", + refresh_token="eyR...", + expires_in=3600, + ) + assert t.expires_in == 3600 + + def test_json_round_trip(self) -> None: + t = TokenResponse( + access_token="access123", + refresh_token="refresh456", + token_type="bearer", + expires_in=1800, + ) + restored = TokenResponse.model_validate_json(t.model_dump_json()) + assert restored == t