Initial commit: Trading Portal - FastAPI + React + PostgreSQL
This commit is contained in:
Executable
+106
@@ -0,0 +1,106 @@
|
||||
from app.schemas.auth import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
RefreshRequest,
|
||||
UserResponse,
|
||||
UserSessionResponse,
|
||||
LogoutRequest,
|
||||
ChangePasswordRequest,
|
||||
)
|
||||
from app.schemas.user import (
|
||||
UserUpdateRequest,
|
||||
AdminCreateUserRequest,
|
||||
AdminResetPasswordRequest,
|
||||
AdminUserUpdateRequest,
|
||||
)
|
||||
from app.schemas.candle import (
|
||||
CandleResponse,
|
||||
CandleListResponse,
|
||||
IndicatorResponse,
|
||||
)
|
||||
from app.schemas.symbol import (
|
||||
SymbolResponse,
|
||||
SymbolSearchResponse,
|
||||
WatchlistResponse,
|
||||
WatchlistCreateRequest,
|
||||
WatchlistUpdateRequest,
|
||||
)
|
||||
from app.schemas.ws_message import (
|
||||
WSSubscription,
|
||||
WSCandleUpdate,
|
||||
WSTickerUpdate,
|
||||
WSConnectionStatus,
|
||||
)
|
||||
from app.schemas.credential import (
|
||||
CredentialResponse,
|
||||
CredentialCreateRequest,
|
||||
CredentialUpdateRequest,
|
||||
)
|
||||
from app.schemas.exchange import (
|
||||
ExchangeResponse,
|
||||
ExchangeCreateRequest,
|
||||
ExchangeUpdateRequest,
|
||||
)
|
||||
from app.schemas.health import (
|
||||
HealthResponse,
|
||||
DetailedHealthResponse,
|
||||
DbHealth,
|
||||
ExchangeHealth,
|
||||
)
|
||||
from app.schemas.strategy import (
|
||||
StrategyEntry,
|
||||
StrategyListResponse,
|
||||
StrategyConfigRequest,
|
||||
STRATEGY_NAMES,
|
||||
STRATEGY_DISPLAY,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# auth
|
||||
"RegisterRequest",
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"RefreshRequest",
|
||||
"UserResponse",
|
||||
"UserSessionResponse",
|
||||
"LogoutRequest",
|
||||
"ChangePasswordRequest",
|
||||
# user
|
||||
"UserUpdateRequest",
|
||||
"AdminUserUpdateRequest",
|
||||
# candle
|
||||
"CandleResponse",
|
||||
"CandleListResponse",
|
||||
"IndicatorResponse",
|
||||
# symbol
|
||||
"SymbolResponse",
|
||||
"SymbolSearchResponse",
|
||||
"WatchlistResponse",
|
||||
"WatchlistCreateRequest",
|
||||
"WatchlistUpdateRequest",
|
||||
# ws_message
|
||||
"WSSubscription",
|
||||
"WSCandleUpdate",
|
||||
"WSTickerUpdate",
|
||||
"WSConnectionStatus",
|
||||
# credential
|
||||
"CredentialResponse",
|
||||
"CredentialCreateRequest",
|
||||
"CredentialUpdateRequest",
|
||||
# exchange
|
||||
"ExchangeResponse",
|
||||
"ExchangeCreateRequest",
|
||||
"ExchangeUpdateRequest",
|
||||
# health
|
||||
"HealthResponse",
|
||||
"DetailedHealthResponse",
|
||||
"DbHealth",
|
||||
"ExchangeHealth",
|
||||
# strategy
|
||||
"StrategyEntry",
|
||||
"StrategyListResponse",
|
||||
"StrategyConfigRequest",
|
||||
"STRATEGY_NAMES",
|
||||
"STRATEGY_DISPLAY",
|
||||
]
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
return v
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
email: str
|
||||
display_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
role: str = "trader"
|
||||
preferences: Optional[dict] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserSessionResponse(BaseModel):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
user_agent: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
is_current: bool
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def new_password_min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("new_password must be at least 8 characters")
|
||||
return v
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CandleResponse(BaseModel):
|
||||
symbol_id: int
|
||||
timeframe: str
|
||||
timestamp: datetime
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
|
||||
|
||||
class CandleListResponse(BaseModel):
|
||||
candles: list[CandleResponse]
|
||||
cursor: Optional[str] = None
|
||||
has_more: bool
|
||||
|
||||
|
||||
class IndicatorResponse(BaseModel):
|
||||
symbol_id: int
|
||||
timeframe: str
|
||||
timestamp: datetime
|
||||
indicators: dict
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class CredentialResponse(BaseModel):
|
||||
id: UUID
|
||||
exchange_id: int
|
||||
exchange_name: str
|
||||
api_key: str
|
||||
is_testnet: bool
|
||||
is_active: bool
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def mask_api_key(cls, v: str) -> str:
|
||||
"""Show full API key so users can copy it (middle chars left visible for reference)."""
|
||||
return v
|
||||
|
||||
|
||||
class CredentialCreateRequest(BaseModel):
|
||||
exchange_id: int
|
||||
api_key: str
|
||||
api_secret: str
|
||||
passphrase: Optional[str] = None
|
||||
is_testnet: bool = False
|
||||
|
||||
|
||||
class CredentialUpdateRequest(BaseModel):
|
||||
api_key: Optional[str] = None
|
||||
api_secret: Optional[str] = None
|
||||
passphrase: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ExchangeResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
display_name: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ExchangeCreateRequest(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
base_url: str
|
||||
ws_url: str
|
||||
|
||||
|
||||
class ExchangeUpdateRequest(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
ws_url: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DbHealth(BaseModel):
|
||||
connected: bool
|
||||
latency_ms: float
|
||||
pool_size: int
|
||||
|
||||
|
||||
class ExchangeHealth(BaseModel):
|
||||
exchange: str
|
||||
connected: bool
|
||||
last_sync: Optional[datetime] = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
uptime: float
|
||||
db_connected: bool
|
||||
|
||||
|
||||
class DetailedHealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
uptime: float
|
||||
db: DbHealth
|
||||
exchange_connections: list[ExchangeHealth]
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
"""Pydantic schemas for real trade responses and requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class RealTradeResponse(BaseModel):
|
||||
"""Single real trade record returned to the frontend."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
exchange: str
|
||||
symbol: str
|
||||
side: str
|
||||
order_type: str
|
||||
amount: float
|
||||
price: Optional[float] = None
|
||||
filled_amount: float
|
||||
status: str
|
||||
pnl: Optional[float] = None
|
||||
pnl_percent: Optional[float] = None
|
||||
order_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("user_id", mode="before")
|
||||
@classmethod
|
||||
def _coerce_user_id(cls, v: object) -> str:
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
class RealTradeListResponse(BaseModel):
|
||||
"""Paginated list of real trades with aggregate stats."""
|
||||
|
||||
trades: list[RealTradeResponse]
|
||||
total: int
|
||||
total_pnl: Optional[float] = None
|
||||
win_rate: Optional[float] = None
|
||||
|
||||
|
||||
class RealTradeCreateRequest(BaseModel):
|
||||
"""Payload to create a real trade record (used after placing an order)."""
|
||||
|
||||
exchange: str
|
||||
symbol: str
|
||||
side: str
|
||||
order_type: str = "market"
|
||||
amount: float
|
||||
price: Optional[float] = None
|
||||
filled_amount: float = 0
|
||||
status: str = "open"
|
||||
order_id: Optional[str] = None
|
||||
|
||||
|
||||
class WinRatePeriod(BaseModel):
|
||||
"""Win-rate data for a single period (daily / weekly / monthly)."""
|
||||
|
||||
trades: int
|
||||
wins: int
|
||||
win_rate: float
|
||||
|
||||
|
||||
class WinRateResponse(BaseModel):
|
||||
"""Win-rate breakdown across daily, weekly, and monthly periods."""
|
||||
|
||||
daily: WinRatePeriod
|
||||
weekly: WinRatePeriod
|
||||
monthly: WinRatePeriod
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
"""Pydantic schemas for signal and trade responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
import json
|
||||
|
||||
|
||||
class SignalResponse(BaseModel):
|
||||
id: int
|
||||
symbol: str
|
||||
exchange: str
|
||||
timeframe: str
|
||||
signal_type: str
|
||||
strength: str
|
||||
price: float
|
||||
timestamp: datetime
|
||||
indicators_snapshot: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
status: str
|
||||
note: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _extract_confidence(self) -> "SignalResponse":
|
||||
if self.confidence is None and self.indicators_snapshot:
|
||||
try:
|
||||
snap = json.loads(self.indicators_snapshot)
|
||||
self.confidence = snap.get("confidence")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
pass
|
||||
return self
|
||||
|
||||
|
||||
class TradeResponse(BaseModel):
|
||||
id: int
|
||||
signal_id: Optional[int] = None
|
||||
symbol: str
|
||||
exchange: str
|
||||
timeframe: str
|
||||
direction: str
|
||||
entry_price: float
|
||||
entry_time: datetime
|
||||
entry_reason: Optional[str] = None
|
||||
exit_price: Optional[float] = None
|
||||
exit_time: Optional[datetime] = None
|
||||
exit_reason: Optional[str] = None
|
||||
quantity: float
|
||||
pnl: Optional[float] = None
|
||||
pnl_percent: Optional[float] = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SignalListResponse(BaseModel):
|
||||
signals: list[SignalResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class TradeListResponse(BaseModel):
|
||||
trades: list[TradeResponse]
|
||||
total: int
|
||||
total_pnl: Optional[float] = None
|
||||
win_rate: Optional[float] = None
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
period: str # "weekly" or "monthly"
|
||||
start_date: str
|
||||
end_date: str
|
||||
total_signals: int
|
||||
total_trades: int
|
||||
wins: int
|
||||
losses: int
|
||||
win_rate: float
|
||||
total_pnl: float
|
||||
best_trade: Optional[TradeResponse] = None
|
||||
worst_trade: Optional[TradeResponse] = None
|
||||
signals_by_type: dict[str, int]
|
||||
symbol_performance: list[dict]
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
"""Pydantic schemas for per-user strategy configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Available strategy names — full list of 13 voting algorithms
|
||||
STRATEGY_NAMES = [
|
||||
"double_bb_rsi",
|
||||
"macd_crossover",
|
||||
"supertrend",
|
||||
"volume_breakout",
|
||||
"ichimoku_cloud",
|
||||
"divergence",
|
||||
"smc",
|
||||
"mtf",
|
||||
"obv",
|
||||
"stoch_rsi",
|
||||
"mfi",
|
||||
"fvg",
|
||||
"candlestick",
|
||||
]
|
||||
|
||||
STRATEGY_DISPLAY: dict[str, str] = {
|
||||
"double_bb_rsi": "Double BB + RSI",
|
||||
"macd_crossover": "MACD Crossover",
|
||||
"supertrend": "SuperTrend",
|
||||
"volume_breakout": "Volume Breakout",
|
||||
"ichimoku_cloud": "Ichimoku Cloud",
|
||||
"divergence": "Divergence",
|
||||
"smc": "Market Structure (SMC)",
|
||||
"mtf": "Multi-Timeframe",
|
||||
"obv": "OBV Crossover",
|
||||
"stoch_rsi": "Stochastic RSI",
|
||||
"mfi": "Money Flow Index",
|
||||
"fvg": "Fair Value Gap",
|
||||
"candlestick": "Candlestick Patterns",
|
||||
}
|
||||
|
||||
|
||||
class StrategyEntry(BaseModel):
|
||||
"""A single strategy with its enabled status."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class StrategyListResponse(BaseModel):
|
||||
"""Response for GET /api/v1/strategies."""
|
||||
|
||||
strategies: list[StrategyEntry]
|
||||
thresholds: dict[str, float] = {}
|
||||
|
||||
|
||||
class StrategyConfigRequest(BaseModel):
|
||||
"""Request body for PUT /api/v1/strategies.
|
||||
|
||||
Only provided fields will be updated.
|
||||
"""
|
||||
|
||||
enabled_strategies: Optional[list[str]] = None
|
||||
thresholds: Optional[dict[str, float]] = None
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SymbolResponse(BaseModel):
|
||||
id: int
|
||||
exchange_id: int
|
||||
symbol: str
|
||||
base: str
|
||||
quote: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SymbolSearchResponse(BaseModel):
|
||||
symbols: list[SymbolResponse]
|
||||
|
||||
|
||||
class WatchlistResponse(BaseModel):
|
||||
id: UUID
|
||||
symbol_id: int
|
||||
symbol: str
|
||||
exchange: str
|
||||
label: Optional[str] = None
|
||||
sort_order: int
|
||||
|
||||
|
||||
class WatchlistCreateRequest(BaseModel):
|
||||
symbol_id: int
|
||||
label: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class WatchlistUpdateRequest(BaseModel):
|
||||
label: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
preferences: Optional[dict] = None
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||
|
||||
def _validate_email(email: str) -> str:
|
||||
if not _EMAIL_RE.match(email):
|
||||
raise ValueError("invalid email format")
|
||||
return email
|
||||
|
||||
def _validate_strong_password(password: str) -> str:
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
if not re.search(r"\d", password):
|
||||
raise ValueError("password must contain at least 1 number")
|
||||
if not re.search(r"[^a-zA-Z0-9]", password):
|
||||
raise ValueError("password must contain at least 1 special character")
|
||||
return password
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class AdminCreateUserRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
display_name: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
role: str = "trader"
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_must_be_valid(cls, v: str) -> str:
|
||||
return _validate_email(v)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_must_be_strong(cls, v: str) -> str:
|
||||
return _validate_strong_password(v)
|
||||
|
||||
|
||||
class AdminResetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def new_password_must_be_strong(cls, v: str) -> str:
|
||||
return _validate_strong_password(v)
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.candle import CandleResponse
|
||||
|
||||
|
||||
class WSSubscription(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
exchange: str
|
||||
action: Literal["subscribe", "unsubscribe"]
|
||||
|
||||
|
||||
class WSCandleUpdate(BaseModel):
|
||||
type: Literal["candle"] = "candle"
|
||||
data: CandleResponse
|
||||
|
||||
|
||||
class WSTickerUpdate(BaseModel):
|
||||
type: Literal["ticker"] = "ticker"
|
||||
symbol: str
|
||||
price: float
|
||||
change_24h: float
|
||||
volume: float
|
||||
|
||||
|
||||
class WSConnectionStatus(BaseModel):
|
||||
type: Literal["connection"] = "connection"
|
||||
status: Literal["connected", "reconnecting", "disconnected"]
|
||||
Reference in New Issue
Block a user