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
@@ -0,0 +1,76 @@
"""add_signal_and_trade_tables
Revision ID: 1b3f1630986f
Revises: add_candle_partitions
Create Date: 2026-06-24 02:37:29.738396
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1b3f1630986f'
down_revision: Union[str, None] = 'add_candle_partitions'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('signals',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('symbol', sa.String(length=50), nullable=False),
sa.Column('exchange', sa.String(length=20), nullable=False),
sa.Column('timeframe', sa.String(length=10), nullable=False),
sa.Column('signal_type', sa.String(length=20), nullable=False, comment='One of: STRONG_BUY, BUY, STRONG_SELL, SELL, CAUTION_LONG, CAUTION_SHORT, SQUEEZE_ALERT'),
sa.Column('strength', sa.String(length=10), nullable=False, comment='STRONG / MODERATE / WEAK'),
sa.Column('price', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False, comment='Candle timestamp that triggered the signal'),
sa.Column('indicators_snapshot', sa.Text(), nullable=True, comment='JSON snapshot of BB values, RSI, SMA, etc.'),
sa.Column('status', sa.String(length=20), nullable=False, comment='ACTIVE / COMPLETED / CANCELLED'),
sa.Column('note', sa.Text(), nullable=True, comment='Optional human note about the signal'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_signals_created_at', 'signals', ['created_at'], unique=False)
op.create_index('ix_signals_status', 'signals', ['status'], unique=False)
op.create_index('ix_signals_symbol_timeframe', 'signals', ['symbol', 'timeframe'], unique=False)
op.create_table('hypothetical_trades',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('signal_id', sa.Integer(), nullable=True, comment='The signal that opened this trade'),
sa.Column('symbol', sa.String(length=50), nullable=False),
sa.Column('exchange', sa.String(length=20), nullable=False),
sa.Column('timeframe', sa.String(length=10), nullable=False),
sa.Column('direction', sa.String(length=10), nullable=False, comment='LONG or SHORT'),
sa.Column('entry_price', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('entry_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('entry_reason', sa.String(length=50), nullable=True, comment='Signal type that opened this trade'),
sa.Column('exit_price', sa.Numeric(precision=20, scale=8), nullable=True),
sa.Column('exit_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('exit_reason', sa.String(length=50), nullable=True, comment='TARGET / STOP_LOSS / REVERSAL / MANUAL'),
sa.Column('quantity', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('pnl', sa.Numeric(precision=20, scale=8), nullable=True, comment='Absolute P&L in quote currency'),
sa.Column('pnl_percent', sa.Numeric(precision=10, scale=4), nullable=True, comment='P&L as percentage'),
sa.Column('status', sa.String(length=10), nullable=False, comment='OPEN or CLOSED'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['signal_id'], ['signals.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_htrades_created_at', 'hypothetical_trades', ['created_at'], unique=False)
op.create_index('ix_htrades_symbol_status', 'hypothetical_trades', ['symbol', 'status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_htrades_symbol_status', table_name='hypothetical_trades')
op.drop_index('ix_htrades_created_at', table_name='hypothetical_trades')
op.drop_table('hypothetical_trades')
op.drop_index('ix_signals_symbol_timeframe', table_name='signals')
op.drop_index('ix_signals_status', table_name='signals')
op.drop_index('ix_signals_created_at', table_name='signals')
op.drop_table('signals')
# ### end Alembic commands ###
+129
View File
@@ -0,0 +1,129 @@
"""initial_schema
Revision ID: 1ea86e9d802a
Revises:
Create Date: 2026-06-23 16:04:51.793154
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1ea86e9d802a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('exchanges',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('display_name', sa.String(length=100), nullable=True),
sa.Column('base_url', sa.String(length=255), nullable=True),
sa.Column('ws_url', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_table('exchange_credentials',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('exchange_id', sa.Integer(), nullable=False),
sa.Column('api_key', sa.String(length=255), nullable=False),
sa.Column('api_secret_enc', sa.String(length=512), nullable=False),
sa.Column('api_secret_iv', sa.String(length=64), nullable=False),
sa.Column('passphrase', sa.String(length=255), nullable=True),
sa.Column('is_testnet', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['exchange_id'], ['exchanges.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('refresh_tokens',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_agent', sa.String(length=255), nullable=True),
sa.Column('ip_address', postgresql.INET(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_table('symbols',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('exchange_id', sa.Integer(), nullable=False),
sa.Column('symbol', sa.String(length=50), nullable=False),
sa.Column('base', sa.String(length=20), nullable=False),
sa.Column('quote', sa.String(length=20), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['exchange_id'], ['exchanges.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('exchange_id', 'symbol', name='uq_symbol_exchange_symbol')
)
op.create_table('candles',
sa.Column('symbol_id', sa.Integer(), nullable=False),
sa.Column('timeframe', sa.String(length=10), nullable=False),
sa.Column('timestamp', postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column('open', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('high', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('low', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('close', sa.Numeric(precision=20, scale=8), nullable=False),
sa.Column('volume', sa.Numeric(precision=30, scale=8), nullable=False),
sa.ForeignKeyConstraint(['symbol_id'], ['symbols.id'], ),
sa.PrimaryKeyConstraint('symbol_id', 'timeframe', 'timestamp', name='pk_candles'),
info={'partitioned': True},
postgresql_partition_by='RANGE (timestamp)'
)
op.create_index('ix_candles_symbol_timeframe_ts_desc', 'candles', ['symbol_id', 'timeframe', sa.text('timestamp DESC')], unique=False)
op.create_table('user_watchlists',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('symbol_id', sa.Integer(), nullable=False),
sa.Column('label', sa.String(length=50), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['symbol_id'], ['symbols.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'symbol_id', name='uq_watchlist_user_symbol')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_watchlists')
op.drop_index('ix_candles_symbol_timeframe_ts_desc', table_name='candles')
op.drop_table('candles')
op.drop_table('symbols')
op.drop_index('ix_refresh_tokens_user_id', table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_table('exchange_credentials')
op.drop_table('users')
op.drop_table('exchanges')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""Add SL/TP columns to hypothetical_trades
Revision ID: 4_add_sl_tp_columns
Revises: add_candle_partitions
Create Date: 2026-06-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "4_add_sl_tp_columns"
down_revision: Union[str, None] = "add_candle_partitions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("hypothetical_trades", sa.Column("stop_loss_price", sa.Numeric(40, 20), nullable=True))
op.add_column("hypothetical_trades", sa.Column("take_profit_price", sa.Numeric(40, 20), nullable=True))
op.add_column("hypothetical_trades", sa.Column("trailing_stop_pct", sa.Numeric(10, 4), nullable=True))
op.create_index("idx_exchanges_name", "exchanges", ["name"])
op.create_index("idx_users_is_active", "users", ["is_active"])
def downgrade() -> None:
op.drop_index("idx_users_is_active", table_name="users")
op.drop_index("idx_exchanges_name", table_name="exchanges")
op.drop_column("hypothetical_trades", "trailing_stop_pct")
op.drop_column("hypothetical_trades", "take_profit_price")
op.drop_column("hypothetical_trades", "stop_loss_price")
+48
View File
@@ -0,0 +1,48 @@
"""create candle partitions
Revision ID: add_candle_partitions
Revises: 1ea86e9d802a
Create Date: 2026-06-23
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_candle_partitions'
down_revision: Union[str, None] = '1ea86e9d802a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create initial partitions for the candles table."""
op.execute("""
CREATE TABLE IF NOT EXISTS candles_2026_06 PARTITION OF candles
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
""")
op.execute("""
CREATE TABLE IF NOT EXISTS candles_2026_07 PARTITION OF candles
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01')
""")
op.execute("""
CREATE TABLE IF NOT EXISTS candles_2026_08 PARTITION OF candles
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
""")
op.execute("""
CREATE TABLE IF NOT EXISTS candles_2026_09 PARTITION OF candles
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01')
""")
op.execute("""
CREATE TABLE IF NOT EXISTS candles_default PARTITION OF candles
DEFAULT
""")
def downgrade() -> None:
"""Remove partitions."""
for partition in ['candles_2026_06', 'candles_2026_07', 'candles_2026_08', 'candles_2026_09', 'candles_default']:
op.execute(f"DROP TABLE IF EXISTS {partition} CASCADE")