Por seis semanas, toda noite, o Solo Trader fechou o dia gravando uma linha de equity que não tinha relação nenhuma com preço de mercado. A conta batia: posições × custo médio + caixa. O número era o valor contábil da carteira, não o de mercado — e ninguém notou, porque o invariante de reconciliação comparava exatamente essas duas coisas e concluía, com razão, que eram iguais.

A causa era um parâmetro. A função de fechamento diário aceitava uma fonte de marcações opcional e, quando ninguém passava nada, caía num default que devolvia um dicionário vazio. O caminho de linha de comando que rodava todo fim de dia nunca passava. Então a marcação a mercado executava com um dicionário vazio: sem preço, sem erro, sem log.

# solo_trader/sleeve_runner.py — o que rodou por seis semanas
def run_daily_eod(marks_source: Callable | None = None):
    marks_source = marks_source or (lambda _a, _s: {})
    marks = marks_source(account, session)
    broker.mark_to_market(marks)   # marks == {} no caminho de CLI

O problema não era aritmética, era um contrato de função

Nada ali calcula errado. A marcação recebeu um dicionário vazio e fez a única coisa sensata com ele: não marcou nada. O default cumpriu exatamente o contrato que a assinatura declarava. O defeito estava no contrato.

Um Callable | None = None com fallback para uma função vazia parece escolha defensiva — evita quebrar se alguém chamar a função sem a dependência. Na prática, converte "esqueceram de passar a fonte de preços" em "seguiu rodando com dado vazio". As duas situações produzem o mesmo rastro observável, e só a segunda o sistema sabe descrever.

O invariante de reconciliação — posições × custo médio + caixa igual a equity — continuou verdadeiro o tempo inteiro. Ele é verdadeiro por construção quando não existe marcação: sem preço de mercado, o equity sai a custo médio, e a comparação vira uma identidade. Reconcile passar não prova que o dado está certo. Prova que a aritmética é coerente com o que entrou nela, inclusive quando o que entrou foi vazio.

A marcação a mercado é o único passo que separa preço real de valor contábil

O Solo Trader é um sistema de trading automatizado que eu construí e opero: Python, Postgres, polars e SQLAlchemy, com dados de mercado da EODHD. Ele roda com gates de risco, stops por drawdown e trilha de auditoria versionada, e lê sinais de dois sistemas irmãos. Um deles é o SignalForge: FastAPI, HTMX, vectorbt e quantstats sobre SQLite. Pontua ativos de 0 a 100 por múltiplos fatores e valida as regras em backtest com custos de transação.

Toda essa trilha depende de uma linha só: o equity de fim de dia. É o número que alimenta o cálculo de drawdown, que aciona os stops, que decidem se uma sleeve continua operando. Se essa linha sai a custo médio em vez de preço de fechamento, a carteira inteira parece mais parada do que é — sem oscilação, sem perda em aberto, sem gatilho de stop.

O fechamento diário faz três coisas em sequência: busca as marcações, entrega ao broker, grava a linha. Das três, só a primeira era opcional.

Seis semanas depois, o formato do estado mudou e o bug apareceu

Nenhum teste falhou. Nenhum lint reclamou. O reconcile passou todas as noites. O que expôs o problema foi a sleeve variante do SignalForge entrar no ar e trazer as primeiras posições overnight em ações.

Até ali, o estado de produção não tinha posição carregada de um dia para o outro em que a diferença entre custo e mercado ficasse visível. Com as primeiras linhas overnight, o padrão saltou: mark == avg_cost em toda posição, sem exceção, todo dia. Preço de fechamento não se comporta assim.

O bug não mudou. O formato dos dados de produção é que mudou, e só então o silêncio virou sintoma. Seis semanas não medem a dificuldade do bug; medem o tempo que a produção levou para gerar um estado em que ele tinha como aparecer.

LEIA TAMBÉM · Caso realUm resultado bom demais é um bug de dados até prova em contrárioQuando um número parece bom demais, quase sempre o dado está errado. Comparar campos entre si — o fechamento tem que estar entre o mínimo e o máximo do dia — revela erros que checar cada campo sozinho nunca revela.

O mesmo default zerou uma estratégia inteira, com outro parâmetro

Achei que fosse caso isolado até encontrar o segundo. Outro parâmetro, a fonte de barras de preço, mesmo tipo de default silencioso. A estratégia mean_reversion_lcap_v1 atravessou a Fase 7 inteira e emitiu 0 sinais.

Zero sinais é resultado plausível para uma estratégia de reversão à média num período sem os gatilhos certos. Foi por isso que ninguém estranhou. A estratégia não estava recebendo barra nenhuma para avaliar: o default devolvia vazio, e lista vazia de barras produz lista vazia de sinais, com toda a coerência do mundo.

Dois bugs, dois parâmetros distintos, o mesmo formato — uma dependência opcional na assinatura, um no-op como default, e um resultado vazio que passa por resultado legítimo.

O fix cabe em três linhas; a parte cara é a pergunta no code review

O padrão que adotei depois disso tem três partes. O default no código-fonte é a implementação de verdade, nunca o no-op. O no-op existe só no fixture do conftest. E o teste que quiser rodar sem a dependência real precisa dizer isso de forma explícita, com a saída declarada no próprio teste.

# solo_trader/sleeve_runner.py — como ficou
def run_daily_eod(marks_source: Callable = fetch_marks):
    marks = marks_source(account, session)
    broker.mark_to_market(marks)

# tests/conftest.py — o no-op mora aqui, e só aqui
@pytest.fixture
def no_marks():
    return lambda _a, _s: {}

A inversão é barata de escrever e cara de sustentar, e o custo vem junto com a recomendação: nenhum lint pega isso sozinho. Callable | None = None é sintaxe válida e comum, e o que torna um caso perigoso é o que o default faz — semântica, não tipo. O que sobra é disciplina de revisão. Cada default opcional numa assinatura de produção vira uma pergunta obrigatória: esse None tem comportamento seguro, ou um comportamento que só finge ser seguro?

Não tenho jeito automático de garantir que a pergunta seja feita. É trabalho humano, repetido, que depende de alguém lembrar na hora de ler o diff.

LEIA TAMBÉM · OpiniãoQuem escreve a funcionalidade não pode escrever o teste que a aprovaSe a mesma IA escreve o programa e escreve a prova que confere o programa, ela pode errar nos dois do mesmo jeito e ninguém nota. Por isso quem confere não deveria ter visto o código por dentro.

O teste que faltava nunca foi "o reconcile passa com o default". Era "a função quebra alto quando ninguém passa a fonte real". Default que devolve estrutura vazia e satisfaz o invariante por construção é a pior categoria de bug que existe: não derruba nada, não deixa rastro e fica esperando o formato do estado de produção mudar para cobrar a conta. Se a dependência é mesmo opcional, o caso feliz é que precisa estar dentro do teste — e o caminho de produção precisa gritar quando alguém esquece de ligar o fio.