Sumário > Projeto de Gestão de Criptomoedas
Revisão do esquema SQL: Bot de Trading Binance (PostgreSQL)
Atualizado em 13/08/2025
A sua proposta cobre parcialmente os requisitos. Pontos fortes: entidades centrais (clients, symbols, strategies, positions, trades, capital_allocations) e índices básicos.
Lacunas em relação aos requisitos funcionais já descritos no projeto:
- Múltiplas chaves/contas por cliente: hoje há apenas
api_key/api_secretemclients. Necessárioaccounts(n:1 comclients) para suportar várias contas por cliente, commarket(spot/futures) etestnet. - Compartilhamento de estratégias: hoje
strategiesestá amarrada aclient_id. Para compartilhar, separe template de instância:strategy_templates(parametrização reutilizável).strategies(instâncias por conta/símbolo, referenciando o template).
- Modo demonstrativo (paper): falta o flag por estratégia/ordem/execução. Adicione
execution_mode(live|demo) emstrategiese propague parapositions/trades. - Parâmetros adicionais:
candle,order_value,fee_pct,slippage, etc. Sugestão:params JSONBnos templates eparams_override JSONBnas instâncias. - Precisão e timezone: criptos exigem alta precisão; use
NUMERIC(38,18)eTIMESTAMPTZ. - Segurança: não armazene chaves em texto claro. Use colunas
*_encpara valores criptografados no app. - Consistência de nomes: o requisito fala em preço mínimo/máximo. Você usa
base_price/top_price. Recomendomin_price/max_price(ou mantenha e documente o mapeamento). - Índices/uniques: incluir chaves únicas para evitar duplicidades de instâncias por conta/símbolo/template e melhorar consultas por período/estratégia.
A seguir, um esquema inicial revisado que incorpora esses pontos.
Esquema revisado (DDL completo)
Observação: se é um projeto novo, use o script abaixo “como está”. Se já houver dados, trate como migração planejada.
-- Banco (opcional se já criado)
-- CREATE DATABASE criptoren;
-- \c criptoren;
-- Extensões úteis
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- pgcrypto pode ser útil para hashes/geração de sal, mas a criptografia será feita na aplicação.
-- Tipos enumerados
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'market_type') THEN
CREATE TYPE market_type AS ENUM ('spot','futures');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'exec_mode') THEN
CREATE TYPE exec_mode AS ENUM ('live','demo');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'position_status') THEN
CREATE TYPE position_status AS ENUM ('open','scheduled','closed');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'trade_side') THEN
CREATE TYPE trade_side AS ENUM ('buy','sell');
END IF;
END $$;
-- ====== Núcleo de clientes e contas ======
CREATE TABLE IF NOT EXISTS clients (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Múltiplas contas por cliente (cada uma com credenciais próprias)
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- apelido (ex.: "Conta Spot Principal")
market market_type NOT NULL DEFAULT 'spot', -- spot/futures
testnet BOOLEAN NOT NULL DEFAULT FALSE,
api_key_enc TEXT NOT NULL, -- armazenar criptografado no app
api_secret_enc TEXT NOT NULL, -- armazenar criptografado no app
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uq_account_per_client UNIQUE (client_id, name)
);
-- ====== Símbolos negociáveis ======
CREATE TABLE IF NOT EXISTS symbols (
id BIGSERIAL PRIMARY KEY,
symbol VARCHAR(20) NOT NULL UNIQUE, -- ex.: BTCUSDT
base_asset VARCHAR(20) NOT NULL,
quote_asset VARCHAR(20) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ====== Estratégias: template reutilizável ======
CREATE TABLE IF NOT EXISTS strategy_templates (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
min_price NUMERIC(38,18) NOT NULL, -- preço mínimo (ex: canal inferior)
max_price NUMERIC(38,18) NOT NULL, -- preço máximo (ex: canal superior)
buy_threshold NUMERIC(10,6) NOT NULL, -- % / fator para compra
sell_threshold NUMERIC(10,6) NOT NULL, -- % / fator para venda
min_profit NUMERIC(10,6) NOT NULL, -- lucro mínimo alvo (fração)
params JSONB NOT NULL DEFAULT '{}'::jsonb,
-- params exemplo: {"candle":"1m","order_value":50.0,"fee_pct":0.0005,"slippage":0.0002}
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CHECK (max_price > min_price)
);
-- ====== Estratégias instanciadas por conta/símbolo ======
CREATE TABLE IF NOT EXISTS strategies (
id BIGSERIAL PRIMARY KEY,
template_id BIGINT NOT NULL REFERENCES strategy_templates(id) ON DELETE RESTRICT,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
symbol_id BIGINT NOT NULL REFERENCES symbols(id) ON DELETE RESTRICT,
execution_mode exec_mode NOT NULL DEFAULT 'demo', -- live|demo
capital_percentage NUMERIC(10,4) NOT NULL CHECK (capital_percentage > 0 AND capital_percentage <= 100),
is_active BOOLEAN DEFAULT TRUE,
params_override JSONB NOT NULL DEFAULT '{}'::jsonb, -- sobrescritas do template
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uq_strategy_per_account UNIQUE (account_id, symbol_id, template_id)
);
CREATE INDEX IF NOT EXISTS idx_strategies_active ON strategies(is_active);
CREATE INDEX IF NOT EXISTS idx_strategies_account ON strategies(account_id);
CREATE INDEX IF NOT EXISTS idx_strategies_symbol ON strategies(symbol_id);
-- ====== Posições (ciclo de trade) ======
CREATE TABLE IF NOT EXISTS positions (
id BIGSERIAL PRIMARY KEY,
strategy_id BIGINT NOT NULL REFERENCES strategies(id) ON DELETE CASCADE,
symbol_id BIGINT NOT NULL REFERENCES symbols(id) ON DELETE RESTRICT,
execution_mode exec_mode NOT NULL, -- snapshot do modo no momento da abertura
entry_price NUMERIC(38,18) NOT NULL,
quantity NUMERIC(38,18) NOT NULL,
fees NUMERIC(38,18) DEFAULT 0,
status position_status NOT NULL DEFAULT 'open',
exit_price NUMERIC(38,18),
scheduled_exit_price NUMERIC(38,18),
channel_exit_date TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_positions_strategy ON positions(strategy_id);
CREATE INDEX IF NOT EXISTS idx_positions_status ON positions(status);
CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions(symbol_id);
-- ====== Trades (execuções efetivas ou simuladas) ======
CREATE TABLE IF NOT EXISTS trades (
id BIGSERIAL PRIMARY KEY,
position_id BIGINT REFERENCES positions(id) ON DELETE SET NULL,
strategy_id BIGINT NOT NULL REFERENCES strategies(id) ON DELETE CASCADE,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
client_id BIGINT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
symbol_id BIGINT NOT NULL REFERENCES symbols(id) ON DELETE RESTRICT,
side trade_side NOT NULL, -- buy|sell
execution_mode exec_mode NOT NULL, -- live|demo (snapshot)
price NUMERIC(38,18) NOT NULL,
quantity NUMERIC(38,18) NOT NULL,
fees NUMERIC(38,18) DEFAULT 0,
profit NUMERIC(38,18),
notes TEXT,
executed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trades_symbol_executed ON trades(symbol_id, executed_at);
CREATE INDEX IF NOT EXISTS idx_trades_strategy_executed ON trades(strategy_id, executed_at);
CREATE INDEX IF NOT EXISTS idx_trades_client_executed ON trades(client_id, executed_at);
-- ====== Alocação de capital ======
CREATE TABLE IF NOT EXISTS capital_allocations (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
strategy_id BIGINT NOT NULL REFERENCES strategies(id) ON DELETE CASCADE,
amount NUMERIC(38,18) NOT NULL,
is_profit BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_capital_client ON capital_allocations(client_id);
CREATE INDEX IF NOT EXISTS idx_capital_strategy ON capital_allocations(strategy_id);
-- ====== Gatilho genérico de updated_at ======
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Aplicar gatilhos onde há updated_at
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT table_name
FROM information_schema.columns
WHERE table_schema='public' AND column_name='updated_at'
LOOP
EXECUTE format($f$
DO $inner$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'update_%1$s_timestamp'
) THEN
CREATE TRIGGER update_%1$s_timestamp
BEFORE UPDATE ON %1$s
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
END IF;
END
$inner$;
$f$, r.table_name);
END LOOP;
END $$;
Como isso cobre seus requisitos
- Várias contas/credenciais por cliente:
accounts(client_id, ...)commarketetestnet. Camposapi_key_enc/api_secret_encpreparados para armazenar chaves criptografadas pela aplicação. - Estratégias compartilháveis: defina uma vez em
strategy_templates; reutilize em múltiplos clientes/contas viastrategies(instâncias). - Parâmetros por estratégia: mínimos explícitos (
min_price,max_price,min_profit, thresholds) +params JSONBparacandle,order_value,fee_pct, etc. Possíveis overrides por instância (params_override). - Modo demonstrativo:
execution_modeemstrategiese snapshot empositions/tradespara auditoria consistente. - Relatórios futuros: índices por
strategy_id,client_id,executed_ate normalização adequada (capital_allocations) facilitam agregações. - Precisão e fuso:
NUMERIC(38,18)eTIMESTAMPTZadequados a cripto e auditoria.
Se quiser evoluir a partir do seu script atual
Se preferir minimizar mudanças:
- Mover credenciais de
clientspara uma novaaccountse preencheraccounts.client_id = clients.id. - Manter
strategiescomo está e adicionar:execution_mode exec_mode NOT NULL DEFAULT 'demo'params JSONB NOT NULL DEFAULT '{}'::jsonbCHECK (top_price > base_price)
Depois, quando precisar compartilhar, introduza gradualmentestrategy_templatese migre.
- Ajustar precisão/timezone: alterar
DECIMAL(18,8)→NUMERIC(38,18)eTIMESTAMP→TIMESTAMPTZconforme necessário.
Exemplos de alterações rápidas:
ALTER TABLE clients DROP COLUMN IF EXISTS api_key, DROP COLUMN IF EXISTS api_secret;
-- 1) criar accounts e relacionar
-- (use INSERT ... SELECT para popular a partir das chaves existentes, se houver backup)
-- 2) complementar strategies
ALTER TABLE strategies
ADD COLUMN IF NOT EXISTS execution_mode exec_mode NOT NULL DEFAULT 'demo',
ADD COLUMN IF NOT EXISTS params JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT chk_price_channel CHECK (top_price > base_price);
-- 3) precisão e timezone (exemplo em uma coluna)
ALTER TABLE positions
ALTER COLUMN entry_price TYPE NUMERIC(38,18),
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
Alembic: Migração inicial do esquema (PostgreSQL)
Abaixo estão os arquivos e passos para versionar o DDL revisado com Alembic.
Passos de setup
# 1) Instalar
pip install alembic
# 2) Inicializar estrutura em src/alembic
alembic init src/alembic
# 3) Configurar URL do banco no alembic.ini (ou usar variável de ambiente)
# abra alembic.ini e ajuste:
# sqlalchemy.url = postgresql+psycopg://user:password@localhost:5432/criptoren
# 4) (Opcional) Deixe o env.py ler DB_URL do .env/ambiente
# substitua o conteúdo de src/alembic/env.py pelo fornecido abaixo
# 5) Criar a revisão inicial com ID fixo
alembic revision -m "init schema" --rev-id 0001_init
# 6) Substitua o conteúdo do arquivo src/alembic/versions/0001_init.py
# pelo código abaixo
# 7) Aplicar
alembic upgrade head
src/alembic/env.py (mínimo, síncrono; lê DB_URL se alembic.ini estiver vazio)
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config, pool
from alembic import context
# Config alembic.ini
config = context.config
# Permite usar variável de ambiente DB_URL como fallback
db_url_env = os.getenv("DB_URL")
if db_url_env:
config.set_main_option("sqlalchemy.url", db_url_env)
# Logging Alembic
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Sem autogenerate neste passo (DDL manual)
target_metadata = None
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
src/alembic/versions/0001_init.py
"""init schema
Revision ID: 0001_init
Revises:
Create Date: 2025-08-13 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql as psql
# revision identifiers, used by Alembic.
revision = "0001_init"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Extensões úteis (idempotentes)
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
# Tipos ENUM (PostgreSQL)
market_type = psql.ENUM("spot", "futures", name="market_type")
exec_mode = psql.ENUM("live", "demo", name="exec_mode")
position_status = psql.ENUM("open", "scheduled", "closed", name="position_status")
trade_side = psql.ENUM("buy", "sell", name="trade_side")
bind = op.get_bind()
market_type.create(bind, checkfirst=True)
exec_mode.create(bind, checkfirst=True)
position_status.create(bind, checkfirst=True)
trade_side.create(bind, checkfirst=True)
# Tabela: clients
op.create_table(
"clients",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=100), nullable=False, unique=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
# Tabela: accounts (múltiplas contas por cliente)
op.create_table(
"accounts",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("client_id", sa.BigInteger(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("market", sa.Enum(name="market_type", native_enum=False), nullable=False, server_default="spot"),
sa.Column("testnet", sa.Boolean(), nullable=False, server_default=sa.text("FALSE")),
sa.Column("api_key_enc", sa.Text(), nullable=False),
sa.Column("api_secret_enc", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("TRUE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.UniqueConstraint("client_id", "name", name="uq_account_per_client"),
)
# Tabela: symbols
op.create_table(
"symbols",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("symbol", sa.String(length=20), nullable=False, unique=True),
sa.Column("base_asset", sa.String(length=20), nullable=False),
sa.Column("quote_asset", sa.String(length=20), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("TRUE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
# Tabela: strategy_templates (reúso/compartilhamento)
op.create_table(
"strategy_templates",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=100), nullable=False, unique=True),
sa.Column("min_price", sa.Numeric(38, 18), nullable=False),
sa.Column("max_price", sa.Numeric(38, 18), nullable=False),
sa.Column("buy_threshold", sa.Numeric(10, 6), nullable=False),
sa.Column("sell_threshold", sa.Numeric(10, 6), nullable=False),
sa.Column("min_profit", sa.Numeric(10, 6), nullable=False),
sa.Column("params", psql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.CheckConstraint("max_price > min_price", name="chk_template_price"),
)
# Tabela: strategies (instâncias por conta/símbolo)
op.create_table(
"strategies",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("template_id", sa.BigInteger(), sa.ForeignKey("strategy_templates.id", ondelete="RESTRICT"), nullable=False),
sa.Column("account_id", sa.BigInteger(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("symbol_id", sa.BigInteger(), sa.ForeignKey("symbols.id", ondelete="RESTRICT"), nullable=False),
sa.Column("execution_mode", sa.Enum(name="exec_mode", native_enum=False), nullable=False, server_default="demo"),
sa.Column("capital_percentage", sa.Numeric(10, 4), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("TRUE")),
sa.Column("params_override", psql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.CheckConstraint("capital_percentage > 0 AND capital_percentage <= 100", name="chk_strategy_capital_pct"),
sa.UniqueConstraint("account_id", "symbol_id", "template_id", name="uq_strategy_per_account"),
)
op.create_index("idx_strategies_active", "strategies", ["is_active"])
op.create_index("idx_strategies_account", "strategies", ["account_id"])
op.create_index("idx_strategies_symbol", "strategies", ["symbol_id"])
# Tabela: positions
op.create_table(
"positions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("strategy_id", sa.BigInteger(), sa.ForeignKey("strategies.id", ondelete="CASCADE"), nullable=False),
sa.Column("symbol_id", sa.BigInteger(), sa.ForeignKey("symbols.id", ondelete="RESTRICT"), nullable=False),
sa.Column("execution_mode", sa.Enum(name="exec_mode", native_enum=False), nullable=False),
sa.Column("entry_price", sa.Numeric(38, 18), nullable=False),
sa.Column("quantity", sa.Numeric(38, 18), nullable=False),
sa.Column("fees", sa.Numeric(38, 18), server_default=sa.text("0"), nullable=False),
sa.Column("status", sa.Enum(name="position_status", native_enum=False), nullable=False, server_default="open"),
sa.Column("exit_price", sa.Numeric(38, 18)),
sa.Column("scheduled_exit_price", sa.Numeric(38, 18)),
sa.Column("channel_exit_date", sa.TIMESTAMP(timezone=True)),
sa.Column("closed_at", sa.TIMESTAMP(timezone=True)),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
op.create_index("idx_positions_strategy", "positions", ["strategy_id"])
op.create_index("idx_positions_status", "positions", ["status"])
op.create_index("idx_positions_symbol", "positions", ["symbol_id"])
# Tabela: trades
op.create_table(
"trades",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("position_id", sa.BigInteger(), sa.ForeignKey("positions.id", ondelete="SET NULL")),
sa.Column("strategy_id", sa.BigInteger(), sa.ForeignKey("strategies.id", ondelete="CASCADE"), nullable=False),
sa.Column("account_id", sa.BigInteger(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("client_id", sa.BigInteger(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("symbol_id", sa.BigInteger(), sa.ForeignKey("symbols.id", ondelete="RESTRICT"), nullable=False),
sa.Column("side", sa.Enum(name="trade_side", native_enum=False), nullable=False),
sa.Column("execution_mode", sa.Enum(name="exec_mode", native_enum=False), nullable=False),
sa.Column("price", sa.Numeric(38, 18), nullable=False),
sa.Column("quantity", sa.Numeric(38, 18), nullable=False),
sa.Column("fees", sa.Numeric(38, 18), server_default=sa.text("0"), nullable=False),
sa.Column("profit", sa.Numeric(38, 18)),
sa.Column("notes", sa.Text()),
sa.Column("executed_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
op.create_index("idx_trades_symbol_executed", "trades", ["symbol_id", "executed_at"])
op.create_index("idx_trades_strategy_executed", "trades", ["strategy_id", "executed_at"])
op.create_index("idx_trades_client_executed", "trades", ["client_id", "executed_at"])
# Tabela: capital_allocations
op.create_table(
"capital_allocations",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("client_id", sa.BigInteger(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("account_id", sa.BigInteger(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("strategy_id", sa.BigInteger(), sa.ForeignKey("strategies.id", ondelete="CASCADE"), nullable=False),
sa.Column("amount", sa.Numeric(38, 18), nullable=False),
sa.Column("is_profit", sa.Boolean(), server_default=sa.text("FALSE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
op.create_index("idx_capital_client", "capital_allocations", ["client_id"])
op.create_index("idx_capital_strategy", "capital_allocations", ["strategy_id"])
# Função e triggers de updated_at
op.execute(
"""
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
# Triggers por tabela com updated_at
for table in ("clients","accounts","symbols","strategy_templates","strategies","positions"):
op.execute(
f"""
CREATE TRIGGER update_{table}_timestamp
BEFORE UPDATE ON {table}
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
"""
)
def downgrade() -> None:
# Remover triggers (ordem inversa não é obrigatória, mas organiza)
for table in ("positions","strategies","strategy_templates","symbols","accounts","clients"):
op.execute(f"DROP TRIGGER IF EXISTS update_{table}_timestamp ON {table};")
# Função
op.execute("DROP FUNCTION IF EXISTS update_timestamp();")
# Índices
op.drop_index("idx_capital_strategy", table_name="capital_allocations")
op.drop_index("idx_capital_client", table_name="capital_allocations")
op.drop_index("idx_trades_client_executed", table_name="trades")
op.drop_index("idx_trades_strategy_executed", table_name="trades")
op.drop_index("idx_trades_symbol_executed", table_name="trades")
op.drop_index("idx_positions_symbol", table_name="positions")
op.drop_index("idx_positions_status", table_name="positions")
op.drop_index("idx_positions_strategy", table_name="positions")
op.drop_index("idx_strategies_symbol", table_name="strategies")
op.drop_index("idx_strategies_account", table_name="strategies")
op.drop_index("idx_strategies_active", table_name="strategies")
# Tabelas (em ordem de dependência)
op.drop_table("capital_allocations")
op.drop_table("trades")
op.drop_table("positions")
op.drop_table("strategies")
op.drop_table("strategy_templates")
op.drop_table("symbols")
op.drop_table("accounts")
op.drop_table("clients")
# Tipos ENUM
bind = op.get_bind()
psql.ENUM(name="trade_side").drop(bind, checkfirst=True)
psql.ENUM(name="position_status").drop(bind, checkfirst=True)
psql.ENUM(name="exec_mode").drop(bind, checkfirst=True)
psql.ENUM(name="market_type").drop(bind, checkfirst=True)
Notas
native_enum=Falsefaz com que as colunas referenciem o tipo já criado (nomeado) no banco. Mantém o controle explícito dos tipos viapsql.ENUM.create/drop.- Todos os
*_atusamTIMESTAMPTZ(sa.TIMESTAMP(timezone=True)) comNOW()e recebem trigger de atualização. - Precisões: valores monetários/quantidades usam
NUMERIC(38,18). - Se você já possuir
alembiccom outroscript_location, ajuste os caminhos de acordo.
Próximos passos
- Definir contrato dos
params(chaves obrigatórias, validações) no código (Pydantic). - Implementar criptografia das chaves no app (ex.:
cryptography.Fernet) antes de persistir emaccounts. - Criar views/materialized views para relatórios (ex.: PnL diário por estratégia).