Initial commit: Trading Portal - FastAPI + React + PostgreSQL

This commit is contained in:
2026-07-03 13:08:09 +00:00
commit 34a1e91541
198 changed files with 35110 additions and 0 deletions
+106
View File
@@ -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",
]
+69
View File
@@ -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
+29
View File
@@ -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
+34
View File
@@ -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
+24
View File
@@ -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
+31
View File
@@ -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]
+78
View File
@@ -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
+89
View File
@@ -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]
+66
View File
@@ -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
+37
View File
@@ -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
+63
View File
@@ -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)
+31
View File
@@ -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"]