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
+24
View File
@@ -0,0 +1,24 @@
from app.models.user import User
from app.models.exchange import Exchange
from app.models.symbol import Symbol
from app.models.candle import Candle
from app.models.watchlist import Watchlist
from app.models.credential import ExchangeCredential
from app.models.refresh_token import RefreshToken
from app.models.real_trade import RealTrade
from app.models.signal import Signal, HypotheticalTrade
from app.models.audit_log import AuditLog
__all__ = [
"User",
"Exchange",
"Symbol",
"Candle",
"Watchlist",
"ExchangeCredential",
"RefreshToken",
"Signal",
"HypotheticalTrade",
"RealTrade",
"AuditLog",
]
+71
View File
@@ -0,0 +1,71 @@
"""AlertCondition ORM model — multi-condition user alerts."""
from __future__ import annotations
from datetime import datetime
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AlertCondition(Base):
"""A user-defined multi-condition alert.
Each alert consists of a name, a list of conditions (stored as JSON),
and notification preferences. Alerts are evaluated after each signal
generation; all active alerts whose conditions are satisfied trigger
a notification.
"""
__tablename__ = "alert_conditions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False,
comment="The user who owns this alert",
)
name: Mapped[str] = mapped_column(
String(100), nullable=False, comment="Human-readable alert name"
)
conditions: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="Array of condition objects, e.g. [{'indicator': 'rsi', 'operator': '>', 'value': 70, 'timeframe': '1h'}]",
)
notify_platform: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="telegram",
comment="Notification channel: 'telegram', 'discord', or 'both'",
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=datetime.now
)
__table_args__ = (
Index("ix_alert_conditions_user_active", "user_id", "is_active"),
)
def __repr__(self) -> str:
return f"<AlertCondition id={self.id} name={self.name!r} user_id={self.user_id}>"
+48
View File
@@ -0,0 +1,48 @@
"""Audit Log model for tracking actions across the system."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import JSON, DateTime, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True, index=True
)
action: Mapped[str] = mapped_column(
String(50), nullable=False
)
resource: Mapped[str] = mapped_column(
String(100), nullable=False
)
details: Mapped[dict | None] = mapped_column(
JSON, nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
__table_args__ = (
Index("ix_audit_logs_action_created_at", "action", "created_at"),
)
def __repr__(self) -> str:
return (
f"<AuditLog id={self.id} action={self.action!r} "
f"resource={self.resource!r}>"
)
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
ForeignKey,
Index,
Integer,
Numeric,
PrimaryKeyConstraint,
String,
desc,
)
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Candle(Base):
__tablename__ = "candles"
symbol_id: Mapped[int] = mapped_column(
Integer, ForeignKey("symbols.id"), nullable=False
)
timeframe: Mapped[str] = mapped_column(
String(10), nullable=False
)
timestamp: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False
)
open: Mapped[Decimal] = mapped_column(
Numeric(20, 8), nullable=False
)
high: Mapped[Decimal] = mapped_column(
Numeric(20, 8), nullable=False
)
low: Mapped[Decimal] = mapped_column(
Numeric(20, 8), nullable=False
)
close: Mapped[Decimal] = mapped_column(
Numeric(20, 8), nullable=False
)
volume: Mapped[Decimal] = mapped_column(
Numeric(30, 8), nullable=False
)
__table_args__ = (
PrimaryKeyConstraint(
"symbol_id", "timeframe", "timestamp",
name="pk_candles",
),
Index(
"ix_candles_symbol_timeframe_ts_desc",
"symbol_id",
"timeframe",
desc("timestamp"),
),
{
"postgresql_partition_by": "RANGE (timestamp)",
"info": {"partitioned": True},
},
)
def __repr__(self) -> str:
return (
f"<Candle symbol_id={self.symbol_id} "
f"timeframe={self.timeframe!r} "
f"timestamp={self.timestamp}>"
)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class ExchangeCredential(Base):
__tablename__ = "exchange_credentials"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=func.gen_random_uuid(),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
exchange_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("exchanges.id"),
nullable=False,
)
api_key: Mapped[str] = mapped_column(
String(255), nullable=False
)
api_secret_enc: Mapped[str] = mapped_column(
String(512), nullable=False
)
api_secret_iv: Mapped[str] = mapped_column(
String(64), nullable=False
)
passphrase: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
is_testnet: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship(
"User", back_populates="credentials"
)
exchange: Mapped["Exchange"] = relationship(
"Exchange", back_populates="credentials"
)
def __repr__(self) -> str:
return (
f"<ExchangeCredential id={self.id} "
f"user_id={self.user_id} "
f"exchange_id={self.exchange_id}>"
)
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, Index, Integer, String
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class Exchange(Base):
__tablename__ = "exchanges"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False
)
display_name: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
base_url: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
ws_url: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
# P2-8: Index for active exchanges
__table_args__ = (
Index("ix_exchanges_is_active", "is_active"),
)
# Relationships
symbols: Mapped[list["Symbol"]] = relationship(
"Symbol", back_populates="exchange", cascade="all, delete-orphan"
)
credentials: Mapped[list["ExchangeCredential"]] = relationship(
"ExchangeCredential",
back_populates="exchange",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Exchange id={self.id} name={self.name!r}>"
+95
View File
@@ -0,0 +1,95 @@
"""RealTrade ORM model for actual exchange trade tracking."""
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
)
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RealTrade(Base):
"""A real trade placed on a connected exchange and persisted locally."""
__tablename__ = "real_trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False,
comment="FK to the user who placed this trade",
)
exchange: Mapped[str] = mapped_column(String(20), nullable=False)
symbol: Mapped[str] = mapped_column(String(50), nullable=False)
side: Mapped[str] = mapped_column(
String(10), nullable=False, comment="buy or sell"
)
order_type: Mapped[str] = mapped_column(
String(10), nullable=False, default="market",
comment="market or limit",
)
# Order details
amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
price: Mapped[Decimal | None] = mapped_column(
Numeric(20, 8), nullable=True,
comment="Limit price (null for market orders)",
)
filled_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 8), nullable=False, default=Decimal("0"),
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="open",
comment="open / filled / cancelled / rejected",
)
# P&L (computed when closed)
pnl: Mapped[Decimal | None] = mapped_column(
Numeric(20, 8), nullable=True,
comment="Realised P&L in quote currency",
)
pnl_percent: Mapped[Decimal | None] = mapped_column(
Numeric(10, 4), nullable=True,
comment="P&L as percentage",
)
# Exchange reference
order_id: Mapped[str | None] = mapped_column(
String(100), nullable=True,
comment="Exchange-side order ID",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=datetime.utcnow,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (
Index("ix_real_trades_user_id", "user_id"),
Index("ix_real_trades_symbol", "symbol"),
Index("ix_real_trades_status", "status"),
Index("ix_real_trades_created_at", "created_at"),
)
def __repr__(self) -> str:
return (
f"<RealTrade #{self.id} {self.side.upper()} "
f"{self.amount} {self.symbol} on {self.exchange} "
f"status={self.status}>"
)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import INET, UUID, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=func.gen_random_uuid(),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token_hash: Mapped[str] = mapped_column(
String(64), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False
)
revoked: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
user_agent: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
ip_address: Mapped[str | None] = mapped_column(
INET, nullable=True
)
__table_args__ = (
Index("ix_refresh_tokens_user_id", "user_id"),
)
# Relationships
user: Mapped["User"] = relationship(
"User", back_populates="refresh_tokens"
)
def __repr__(self) -> str:
return f"<RefreshToken id={self.id} user_id={self.user_id}>"
+98
View File
@@ -0,0 +1,98 @@
"""Signal and HypotheticalTrade ORM models for trading signal detection."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Signal(Base):
"""A detected trading signal based on Double Bollinger Bands + RSI analysis."""
__tablename__ = "signals"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
exchange: Mapped[str] = mapped_column(String(20), nullable=False, default="mexc")
timeframe: Mapped[str] = mapped_column(String(10), nullable=False)
signal_type: Mapped[str] = mapped_column(String(30), nullable=False)
strength: Mapped[str] = mapped_column(String(20), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
indicators_snapshot: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE")
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
__table_args__ = (
Index("ix_signals_symbol_created", "symbol", "created_at"),
)
class HypotheticalTrade(Base):
"""A hypothetical (paper) trade automatically entered on signal generation.
When a STRONG_BUY or BUY signal fires, a LONG trade is opened.
When a STRONG_SELL or SELL signal fires, a SHORT trade is opened.
Trades are closed when an opposing signal fires or when stopped out.
"""
__tablename__ = "hypothetical_trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=True,
comment="The user who owns this trade",
)
signal_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("signals.id"), nullable=True,
comment="The signal that opened this trade"
)
symbol: Mapped[str] = mapped_column(String(50), nullable=False)
exchange: Mapped[str] = mapped_column(String(20), nullable=False, default="mexc")
timeframe: Mapped[str] = mapped_column(String(10), nullable=False)
direction: Mapped[str] = mapped_column(String(10), nullable=False)
entry_price: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
entry_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
entry_reason: Mapped[str | None] = mapped_column(String(30), nullable=True)
exit_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
exit_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
exit_reason: Mapped[str | None] = mapped_column(String(30), nullable=True)
quantity: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
pnl: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
pnl_percent: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
status: Mapped[str] = mapped_column(String(10), nullable=False, default="OPEN")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_hyp_trades_symbol", "symbol"),
Index("ix_hyp_trades_status", "status"),
)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Symbol(Base):
__tablename__ = "symbols"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
exchange_id: Mapped[int] = mapped_column(
Integer, ForeignKey("exchanges.id"), nullable=False
)
symbol: Mapped[str] = mapped_column(
String(50), nullable=False
)
base: Mapped[str] = mapped_column(
String(20), nullable=False
)
quote: Mapped[str] = mapped_column(
String(20), nullable=False
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
is_trading: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
__table_args__ = (
UniqueConstraint("exchange_id", "symbol", name="uq_symbol_exchange_symbol"),
Index("ix_symbols_is_active", "is_active"), # P2-8
)
# Relationships
exchange: Mapped["Exchange"] = relationship(
"Exchange", back_populates="symbols"
)
watchlists: Mapped[list["Watchlist"]] = relationship(
"Watchlist", back_populates="symbol", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Symbol id={self.id} symbol={self.symbol!r}>"
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, JSON, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=func.gen_random_uuid(),
)
username: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False
)
password_hash: Mapped[str] = mapped_column(
String(255), nullable=False
)
display_name: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
is_admin: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
role: Mapped[str] = mapped_column(
String(20), default="trader", nullable=False
)
preferences: Mapped[dict | None] = mapped_column(
JSON, default={"default_exchange": "mexc", "default_timeframe": "1h"}, nullable=True
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
watchlists: Mapped[list["Watchlist"]] = relationship(
"Watchlist", back_populates="user", cascade="all, delete-orphan"
)
credentials: Mapped[list["ExchangeCredential"]] = relationship(
"ExchangeCredential",
back_populates="user",
cascade="all, delete-orphan",
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username!r}>"
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class Watchlist(Base):
__tablename__ = "user_watchlists"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=func.gen_random_uuid(),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
symbol_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("symbols.id", ondelete="CASCADE"),
nullable=False,
)
label: Mapped[str | None] = mapped_column(
String(50), nullable=True
)
sort_order: Mapped[int] = mapped_column(
Integer, default=0, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint(
"user_id", "symbol_id", name="uq_watchlist_user_symbol"
),
)
# Relationships
user: Mapped["User"] = relationship(
"User", back_populates="watchlists"
)
symbol: Mapped["Symbol"] = relationship(
"Symbol", back_populates="watchlists"
)
def __repr__(self) -> str:
return (
f"<Watchlist id={self.id} "
f"user_id={self.user_id} "
f"symbol_id={self.symbol_id}>"
)