Spaces:
Sleeping
Sleeping
Commit ·
fa8f34a
1
Parent(s): 29f5134
Add libsql dependency
Browse files
server.py
CHANGED
|
@@ -4,9 +4,11 @@ Backend FinanceAI (SQLite/Turso + Groq)
|
|
| 4 |
- Endpoints de gráficos (temporal e por categoria)
|
| 5 |
- Módulo de Cartão de Crédito: cartões, compras, fatura (YYYY-MM) e pagamento
|
| 6 |
- Relatórios: exportação por competência (normal + cartão)
|
| 7 |
-
-
|
|
|
|
|
|
|
| 8 |
* Compras no cartão contam como DESPESA na competência da fatura (invoice_ym).
|
| 9 |
-
* Pagamento da fatura é movimento de CAIXA (saída da conta), mas NÃO é despesa “real” (
|
| 10 |
=> por padrão, os endpoints de despesas EXCLUEM a categoria de pagamento de fatura.
|
| 11 |
|
| 12 |
Como rodar:
|
|
@@ -29,6 +31,7 @@ Como rodar:
|
|
| 29 |
|
| 30 |
# CORS
|
| 31 |
CORS_ORIGINS="http://127.0.0.1:5500,http://localhost:5500"
|
|
|
|
| 32 |
5) uvicorn server:app --reload --port 8000
|
| 33 |
"""
|
| 34 |
|
|
@@ -41,8 +44,9 @@ import os
|
|
| 41 |
import sqlite3
|
| 42 |
import urllib.error
|
| 43 |
import urllib.request
|
| 44 |
-
from contextlib import contextmanager
|
| 45 |
from datetime import datetime
|
|
|
|
| 46 |
from typing import Any, Dict, Literal, Optional
|
| 47 |
|
| 48 |
from dotenv import load_dotenv
|
|
@@ -50,7 +54,6 @@ from fastapi import FastAPI, HTTPException
|
|
| 50 |
from fastapi.middleware.cors import CORSMiddleware
|
| 51 |
from fastapi.responses import StreamingResponse
|
| 52 |
from pydantic import BaseModel, Field
|
| 53 |
-
from contextlib import asynccontextmanager
|
| 54 |
|
| 55 |
# ============================================================
|
| 56 |
# Env
|
|
@@ -219,10 +222,7 @@ def q_scalar(conn: Any, sql: str, params: tuple = (), default: float = 0.0) -> f
|
|
| 219 |
|
| 220 |
|
| 221 |
def _maybe_sync(conn: Any) -> None:
|
| 222 |
-
"""
|
| 223 |
-
Se for libSQL, tenta sincronizar sem quebrar o fluxo.
|
| 224 |
-
Observação: em ambiente local puro (sqlite), não faz nada.
|
| 225 |
-
"""
|
| 226 |
if not USE_TURSO:
|
| 227 |
return
|
| 228 |
try:
|
|
@@ -263,7 +263,6 @@ def get_conn():
|
|
| 263 |
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 264 |
conn.row_factory = sqlite3.Row
|
| 265 |
|
| 266 |
-
# Garantir FKs no SQLite/libSQL
|
| 267 |
try:
|
| 268 |
conn.execute("PRAGMA foreign_keys = ON;")
|
| 269 |
except Exception:
|
|
@@ -356,6 +355,15 @@ def init_db() -> None:
|
|
| 356 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_invoice ON card_purchases(card_id, invoice_ym);")
|
| 357 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
conn.commit()
|
| 360 |
_maybe_sync(conn)
|
| 361 |
|
|
@@ -371,7 +379,6 @@ def seed_defaults() -> None:
|
|
| 371 |
("Carteira", "Pessoal", "carteira", now_iso()),
|
| 372 |
)
|
| 373 |
|
| 374 |
-
# Categorias base (inclui bucket do cartão e categoria de pagamento)
|
| 375 |
if cat_count == 0:
|
| 376 |
base = [
|
| 377 |
"Alimentação",
|
|
@@ -393,13 +400,12 @@ def seed_defaults() -> None:
|
|
| 393 |
|
| 394 |
|
| 395 |
# ============================================================
|
| 396 |
-
# Lifespan
|
| 397 |
# ============================================================
|
| 398 |
@asynccontextmanager
|
| 399 |
async def lifespan(app: FastAPI):
|
| 400 |
init_db()
|
| 401 |
seed_defaults()
|
| 402 |
-
# Sincroniza 1x na subida (se for Turso/libSQL)
|
| 403 |
if USE_TURSO:
|
| 404 |
try:
|
| 405 |
with db() as conn:
|
|
@@ -407,10 +413,9 @@ async def lifespan(app: FastAPI):
|
|
| 407 |
except Exception:
|
| 408 |
pass
|
| 409 |
yield
|
| 410 |
-
# shutdown: nada a fazer (conexões são por-request)
|
| 411 |
|
| 412 |
|
| 413 |
-
app = FastAPI(title="FinanceAI API", version="1.
|
| 414 |
|
| 415 |
app.add_middleware(
|
| 416 |
CORSMiddleware,
|
|
@@ -546,6 +551,28 @@ class MonthlyReportOut(BaseModel):
|
|
| 546 |
card_purchases: list[Dict[str, Any]]
|
| 547 |
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
# ============================================================
|
| 550 |
# Healthcheck
|
| 551 |
# ============================================================
|
|
@@ -611,7 +638,6 @@ def list_categories():
|
|
| 611 |
|
| 612 |
@app.post("/categories", response_model=CategoryOut)
|
| 613 |
def create_category(payload: CategoryIn):
|
| 614 |
-
# Protege nomes reservados (opcional; evita bagunça semântica)
|
| 615 |
if payload.name.strip() == "":
|
| 616 |
raise HTTPException(status_code=422, detail="Nome de categoria inválido.")
|
| 617 |
|
|
@@ -641,7 +667,6 @@ def delete_category(category_id: int):
|
|
| 641 |
if not row:
|
| 642 |
raise HTTPException(status_code=404, detail="Categoria não encontrada.")
|
| 643 |
|
| 644 |
-
# Evita deletar a categoria “contábil” do pagamento do cartão
|
| 645 |
if row["name"] == CATEGORY_CARD_PAYMENT:
|
| 646 |
raise HTTPException(status_code=409, detail="Categoria reservada. Não pode ser removida.")
|
| 647 |
|
|
@@ -659,10 +684,6 @@ def delete_category(category_id: int):
|
|
| 659 |
# ============================================================
|
| 660 |
@app.get("/transactions", response_model=list[TransactionOut])
|
| 661 |
def list_transactions(year: Optional[int] = None, month: Optional[int] = None, limit: int = 200):
|
| 662 |
-
"""
|
| 663 |
-
Retorna transações (caixa).
|
| 664 |
-
Observação: pagamentos de fatura são transações de caixa e ficam aqui.
|
| 665 |
-
"""
|
| 666 |
if limit < 1 or limit > 2000:
|
| 667 |
raise HTTPException(status_code=422, detail="limit deve estar entre 1 e 2000.")
|
| 668 |
|
|
@@ -682,13 +703,6 @@ def list_transactions(year: Optional[int] = None, month: Optional[int] = None, l
|
|
| 682 |
|
| 683 |
@app.get("/transactions/combined")
|
| 684 |
def list_transactions_combined(year: int, month: int, limit: int = 2000, order: Literal["asc", "desc"] = "desc"):
|
| 685 |
-
"""
|
| 686 |
-
Extrato “combinado” (competência):
|
| 687 |
-
- cash: transactions do mês (YYYY-MM)
|
| 688 |
-
- card: compras cujo invoice_ym = YYYY-MM
|
| 689 |
-
|
| 690 |
-
Retorno unificado com campo 'source' ('cash'|'card').
|
| 691 |
-
"""
|
| 692 |
if month < 1 or month > 12:
|
| 693 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 694 |
if limit < 1 or limit > 5000:
|
|
@@ -740,8 +754,6 @@ def list_transactions_combined(year: int, month: int, limit: int = 2000, order:
|
|
| 740 |
)
|
| 741 |
|
| 742 |
rows = cash + card
|
| 743 |
-
|
| 744 |
-
# Ordenação previsível (front costuma querer desc)
|
| 745 |
rows.sort(key=lambda r: (r.get("date") or "", int(r.get("id") or 0)), reverse=(order == "desc"))
|
| 746 |
return rows[:limit]
|
| 747 |
|
|
@@ -795,11 +807,6 @@ def delete_transaction(tx_id: int):
|
|
| 795 |
# ============================================================
|
| 796 |
@app.get("/summary", response_model=SummaryOut)
|
| 797 |
def get_summary(year: int, month: int, exclude_card_payments: bool = True):
|
| 798 |
-
"""
|
| 799 |
-
Sumário de CAIXA (transactions).
|
| 800 |
-
Por padrão, exclui pagamentos de fatura da categoria CATEGORY_CARD_PAYMENT,
|
| 801 |
-
porque isso não é “despesa real”, é quitação de passivo.
|
| 802 |
-
"""
|
| 803 |
if month < 1 or month > 12:
|
| 804 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 805 |
|
|
@@ -849,13 +856,6 @@ def get_summary(year: int, month: int, exclude_card_payments: bool = True):
|
|
| 849 |
|
| 850 |
@app.get("/summary/combined", response_model=CombinedSummaryOut)
|
| 851 |
def get_summary_combined(year: int, month: int):
|
| 852 |
-
"""
|
| 853 |
-
Sumário por competência (cartão por invoice_ym):
|
| 854 |
-
- income: receitas em transactions
|
| 855 |
-
- expense_cash: despesas em transactions (EXCLUINDO pagamento de fatura)
|
| 856 |
-
- expense_card: soma das compras em card_purchases com invoice_ym=YYYY-MM
|
| 857 |
-
- expense_total = expense_cash + expense_card
|
| 858 |
-
"""
|
| 859 |
if month < 1 or month > 12:
|
| 860 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 861 |
ym = f"{year:04d}-{month:02d}"
|
|
@@ -909,10 +909,6 @@ def get_summary_combined(year: int, month: int):
|
|
| 909 |
# ============================================================
|
| 910 |
@app.get("/charts/timeseries")
|
| 911 |
def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
|
| 912 |
-
"""
|
| 913 |
-
Agregação diária do mês: income/expense por dia (transactions).
|
| 914 |
-
Por padrão, exclui pagamentos de fatura da categoria CATEGORY_CARD_PAYMENT.
|
| 915 |
-
"""
|
| 916 |
if month < 1 or month > 12:
|
| 917 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 918 |
|
|
@@ -953,10 +949,6 @@ def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
|
|
| 953 |
|
| 954 |
@app.get("/charts/categories")
|
| 955 |
def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
|
| 956 |
-
"""
|
| 957 |
-
Total por categoria no mês (transactions).
|
| 958 |
-
Se tx_type='expense', por padrão exclui pagamentos de fatura (CATEGORY_CARD_PAYMENT).
|
| 959 |
-
"""
|
| 960 |
if month < 1 or month > 12:
|
| 961 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 962 |
if tx_type not in ("income", "expense"):
|
|
@@ -1042,7 +1034,6 @@ def delete_card(card_id: int):
|
|
| 1042 |
|
| 1043 |
@app.get("/cards/{card_id}/invoices")
|
| 1044 |
def list_card_invoices(card_id: int):
|
| 1045 |
-
"""Lista competências (invoice_ym) existentes para um cartão."""
|
| 1046 |
with db() as conn:
|
| 1047 |
card = q_one(conn, "SELECT id FROM credit_cards WHERE id=?", (card_id,))
|
| 1048 |
if not card:
|
|
@@ -1134,7 +1125,6 @@ def create_card_purchase(payload: CardPurchaseIn):
|
|
| 1134 |
|
| 1135 |
@app.patch("/cards/purchases/{purchase_id}", response_model=dict)
|
| 1136 |
def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
|
| 1137 |
-
"""Atualiza status de uma compra (pending/paid)."""
|
| 1138 |
if payload.status not in ("pending", "paid"):
|
| 1139 |
raise HTTPException(status_code=422, detail="status inválido.")
|
| 1140 |
|
|
@@ -1206,12 +1196,6 @@ def invoice_summary(card_id: int, invoice_ym: str):
|
|
| 1206 |
|
| 1207 |
@app.post("/cards/pay-invoice")
|
| 1208 |
def pay_invoice(payload: PayInvoiceIn):
|
| 1209 |
-
"""
|
| 1210 |
-
Paga uma fatura:
|
| 1211 |
-
1) marca compras pendentes como pagas
|
| 1212 |
-
2) cria transação de CAIXA para baixar o saldo da conta
|
| 1213 |
-
- categoria: CATEGORY_CARD_PAYMENT (por padrão, NÃO entra como “despesa real” nos somatórios)
|
| 1214 |
-
"""
|
| 1215 |
parse_ym(payload.invoice_ym)
|
| 1216 |
parse_date_yyyy_mm_dd(payload.pay_date)
|
| 1217 |
|
|
@@ -1238,7 +1222,6 @@ def pay_invoice(payload: PayInvoiceIn):
|
|
| 1238 |
if total <= 0:
|
| 1239 |
return {"ok": True, "message": "Nada pendente para pagar nesta fatura.", "paid_total": 0.0}
|
| 1240 |
|
| 1241 |
-
# 1) Marca compras como pagas
|
| 1242 |
conn.execute(
|
| 1243 |
"""
|
| 1244 |
UPDATE card_purchases
|
|
@@ -1248,7 +1231,6 @@ def pay_invoice(payload: PayInvoiceIn):
|
|
| 1248 |
(now_iso(), payload.card_id, payload.invoice_ym),
|
| 1249 |
)
|
| 1250 |
|
| 1251 |
-
# 2) Transação de caixa (quitação)
|
| 1252 |
desc = f"Pagamento fatura {card['name']} ({payload.invoice_ym})"
|
| 1253 |
conn.execute(
|
| 1254 |
"""
|
|
@@ -1268,7 +1250,6 @@ def pay_invoice(payload: PayInvoiceIn):
|
|
| 1268 |
# ============================================================
|
| 1269 |
@app.get("/charts/card/categories")
|
| 1270 |
def chart_card_categories(card_id: int, invoice_ym: str):
|
| 1271 |
-
"""Total por categoria na fatura (invoice_ym) de um cartão."""
|
| 1272 |
parse_ym(invoice_ym)
|
| 1273 |
|
| 1274 |
with db() as conn:
|
|
@@ -1291,11 +1272,6 @@ def chart_card_categories(card_id: int, invoice_ym: str):
|
|
| 1291 |
|
| 1292 |
@app.get("/charts/combined/categories")
|
| 1293 |
def chart_combined_categories(year: int, month: int):
|
| 1294 |
-
"""
|
| 1295 |
-
Categorias de DESPESA por competência, combinando:
|
| 1296 |
-
- transactions (expense) no mês, EXCLUINDO pagamento de fatura
|
| 1297 |
-
- card_purchases com invoice_ym no mês
|
| 1298 |
-
"""
|
| 1299 |
if month < 1 or month > 12:
|
| 1300 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1301 |
ym = f"{year:04d}-{month:02d}"
|
|
@@ -1329,11 +1305,6 @@ def chart_combined_categories(year: int, month: int):
|
|
| 1329 |
|
| 1330 |
@app.get("/charts/combined/timeseries")
|
| 1331 |
def chart_combined_timeseries(year: int, month: int):
|
| 1332 |
-
"""
|
| 1333 |
-
Série temporal diária por competência, combinando:
|
| 1334 |
-
- transactions (despesas) no mês, EXCLUINDO pagamento de fatura
|
| 1335 |
-
- card_purchases (invoice_ym do mês) agrupadas por purchase_date
|
| 1336 |
-
"""
|
| 1337 |
if month < 1 or month > 12:
|
| 1338 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1339 |
ym = f"{year:04d}-{month:02d}"
|
|
@@ -1415,14 +1386,6 @@ def report_monthly(year: int, month: int):
|
|
| 1415 |
|
| 1416 |
@app.get("/reports/export")
|
| 1417 |
def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
|
| 1418 |
-
"""
|
| 1419 |
-
Exporta por competência (ym):
|
| 1420 |
-
- transactions do mês (caixa)
|
| 1421 |
-
- card_purchases com invoice_ym do mês (cartão)
|
| 1422 |
-
|
| 1423 |
-
fmt=json => retorna JSON
|
| 1424 |
-
fmt=csv => retorna CSV (download)
|
| 1425 |
-
"""
|
| 1426 |
if month < 1 or month > 12:
|
| 1427 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1428 |
ym = f"{year:04d}-{month:02d}"
|
|
@@ -1520,7 +1483,7 @@ def ask_ai(payload: AIRequest):
|
|
| 1520 |
headers={
|
| 1521 |
"Content-Type": "application/json",
|
| 1522 |
"Authorization": f"Bearer {GROQ_API_KEY}",
|
| 1523 |
-
"User-Agent": "FinanceAI/1.
|
| 1524 |
},
|
| 1525 |
)
|
| 1526 |
|
|
@@ -1546,3 +1509,386 @@ def ask_ai(payload: AIRequest):
|
|
| 1546 |
answer = "Não consegui extrair a resposta do Groq. Verifique o payload retornado no servidor."
|
| 1547 |
|
| 1548 |
return {"answer_md": answer}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
- Endpoints de gráficos (temporal e por categoria)
|
| 5 |
- Módulo de Cartão de Crédito: cartões, compras, fatura (YYYY-MM) e pagamento
|
| 6 |
- Relatórios: exportação por competência (normal + cartão)
|
| 7 |
+
- Módulo de Projeção (ML leve): treino e previsão por competência (cash + cartão)
|
| 8 |
+
|
| 9 |
+
IMPORTANTE (evitar dupla contagem):
|
| 10 |
* Compras no cartão contam como DESPESA na competência da fatura (invoice_ym).
|
| 11 |
+
* Pagamento da fatura é movimento de CAIXA (saída da conta), mas NÃO é despesa “real” (quitação de passivo).
|
| 12 |
=> por padrão, os endpoints de despesas EXCLUEM a categoria de pagamento de fatura.
|
| 13 |
|
| 14 |
Como rodar:
|
|
|
|
| 31 |
|
| 32 |
# CORS
|
| 33 |
CORS_ORIGINS="http://127.0.0.1:5500,http://localhost:5500"
|
| 34 |
+
|
| 35 |
5) uvicorn server:app --reload --port 8000
|
| 36 |
"""
|
| 37 |
|
|
|
|
| 44 |
import sqlite3
|
| 45 |
import urllib.error
|
| 46 |
import urllib.request
|
| 47 |
+
from contextlib import contextmanager, asynccontextmanager
|
| 48 |
from datetime import datetime
|
| 49 |
+
from math import sin, cos, pi
|
| 50 |
from typing import Any, Dict, Literal, Optional
|
| 51 |
|
| 52 |
from dotenv import load_dotenv
|
|
|
|
| 54 |
from fastapi.middleware.cors import CORSMiddleware
|
| 55 |
from fastapi.responses import StreamingResponse
|
| 56 |
from pydantic import BaseModel, Field
|
|
|
|
| 57 |
|
| 58 |
# ============================================================
|
| 59 |
# Env
|
|
|
|
| 222 |
|
| 223 |
|
| 224 |
def _maybe_sync(conn: Any) -> None:
|
| 225 |
+
"""Se for libSQL, tenta sincronizar sem quebrar o fluxo."""
|
|
|
|
|
|
|
|
|
|
| 226 |
if not USE_TURSO:
|
| 227 |
return
|
| 228 |
try:
|
|
|
|
| 263 |
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 264 |
conn.row_factory = sqlite3.Row
|
| 265 |
|
|
|
|
| 266 |
try:
|
| 267 |
conn.execute("PRAGMA foreign_keys = ON;")
|
| 268 |
except Exception:
|
|
|
|
| 355 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_invoice ON card_purchases(card_id, invoice_ym);")
|
| 356 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
|
| 357 |
|
| 358 |
+
# ---- ML models (forecast) ----
|
| 359 |
+
conn.execute("""
|
| 360 |
+
CREATE TABLE IF NOT EXISTS ml_models (
|
| 361 |
+
name TEXT PRIMARY KEY,
|
| 362 |
+
trained_at TEXT NOT NULL,
|
| 363 |
+
payload_json TEXT NOT NULL
|
| 364 |
+
);
|
| 365 |
+
""")
|
| 366 |
+
|
| 367 |
conn.commit()
|
| 368 |
_maybe_sync(conn)
|
| 369 |
|
|
|
|
| 379 |
("Carteira", "Pessoal", "carteira", now_iso()),
|
| 380 |
)
|
| 381 |
|
|
|
|
| 382 |
if cat_count == 0:
|
| 383 |
base = [
|
| 384 |
"Alimentação",
|
|
|
|
| 400 |
|
| 401 |
|
| 402 |
# ============================================================
|
| 403 |
+
# Lifespan
|
| 404 |
# ============================================================
|
| 405 |
@asynccontextmanager
|
| 406 |
async def lifespan(app: FastAPI):
|
| 407 |
init_db()
|
| 408 |
seed_defaults()
|
|
|
|
| 409 |
if USE_TURSO:
|
| 410 |
try:
|
| 411 |
with db() as conn:
|
|
|
|
| 413 |
except Exception:
|
| 414 |
pass
|
| 415 |
yield
|
|
|
|
| 416 |
|
| 417 |
|
| 418 |
+
app = FastAPI(title="FinanceAI API", version="1.6.0", lifespan=lifespan)
|
| 419 |
|
| 420 |
app.add_middleware(
|
| 421 |
CORSMiddleware,
|
|
|
|
| 551 |
card_purchases: list[Dict[str, Any]]
|
| 552 |
|
| 553 |
|
| 554 |
+
# -------- Forecast (ML leve) --------
|
| 555 |
+
class ForecastTrainOut(BaseModel):
|
| 556 |
+
ok: bool
|
| 557 |
+
basis: str
|
| 558 |
+
trained_at: str
|
| 559 |
+
n_months: int
|
| 560 |
+
start_ym: str
|
| 561 |
+
end_ym: str
|
| 562 |
+
metrics: Dict[str, Any]
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
class ForecastOut(BaseModel):
|
| 566 |
+
ok: bool
|
| 567 |
+
basis: str
|
| 568 |
+
horizon: int
|
| 569 |
+
trained_at: Optional[str]
|
| 570 |
+
history: list[Dict[str, Any]]
|
| 571 |
+
predictions: list[Dict[str, Any]]
|
| 572 |
+
metrics: Dict[str, Any]
|
| 573 |
+
note: str
|
| 574 |
+
|
| 575 |
+
|
| 576 |
# ============================================================
|
| 577 |
# Healthcheck
|
| 578 |
# ============================================================
|
|
|
|
| 638 |
|
| 639 |
@app.post("/categories", response_model=CategoryOut)
|
| 640 |
def create_category(payload: CategoryIn):
|
|
|
|
| 641 |
if payload.name.strip() == "":
|
| 642 |
raise HTTPException(status_code=422, detail="Nome de categoria inválido.")
|
| 643 |
|
|
|
|
| 667 |
if not row:
|
| 668 |
raise HTTPException(status_code=404, detail="Categoria não encontrada.")
|
| 669 |
|
|
|
|
| 670 |
if row["name"] == CATEGORY_CARD_PAYMENT:
|
| 671 |
raise HTTPException(status_code=409, detail="Categoria reservada. Não pode ser removida.")
|
| 672 |
|
|
|
|
| 684 |
# ============================================================
|
| 685 |
@app.get("/transactions", response_model=list[TransactionOut])
|
| 686 |
def list_transactions(year: Optional[int] = None, month: Optional[int] = None, limit: int = 200):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
if limit < 1 or limit > 2000:
|
| 688 |
raise HTTPException(status_code=422, detail="limit deve estar entre 1 e 2000.")
|
| 689 |
|
|
|
|
| 703 |
|
| 704 |
@app.get("/transactions/combined")
|
| 705 |
def list_transactions_combined(year: int, month: int, limit: int = 2000, order: Literal["asc", "desc"] = "desc"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
if month < 1 or month > 12:
|
| 707 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 708 |
if limit < 1 or limit > 5000:
|
|
|
|
| 754 |
)
|
| 755 |
|
| 756 |
rows = cash + card
|
|
|
|
|
|
|
| 757 |
rows.sort(key=lambda r: (r.get("date") or "", int(r.get("id") or 0)), reverse=(order == "desc"))
|
| 758 |
return rows[:limit]
|
| 759 |
|
|
|
|
| 807 |
# ============================================================
|
| 808 |
@app.get("/summary", response_model=SummaryOut)
|
| 809 |
def get_summary(year: int, month: int, exclude_card_payments: bool = True):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 810 |
if month < 1 or month > 12:
|
| 811 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 812 |
|
|
|
|
| 856 |
|
| 857 |
@app.get("/summary/combined", response_model=CombinedSummaryOut)
|
| 858 |
def get_summary_combined(year: int, month: int):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
if month < 1 or month > 12:
|
| 860 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 861 |
ym = f"{year:04d}-{month:02d}"
|
|
|
|
| 909 |
# ============================================================
|
| 910 |
@app.get("/charts/timeseries")
|
| 911 |
def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
if month < 1 or month > 12:
|
| 913 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 914 |
|
|
|
|
| 949 |
|
| 950 |
@app.get("/charts/categories")
|
| 951 |
def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
if month < 1 or month > 12:
|
| 953 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 954 |
if tx_type not in ("income", "expense"):
|
|
|
|
| 1034 |
|
| 1035 |
@app.get("/cards/{card_id}/invoices")
|
| 1036 |
def list_card_invoices(card_id: int):
|
|
|
|
| 1037 |
with db() as conn:
|
| 1038 |
card = q_one(conn, "SELECT id FROM credit_cards WHERE id=?", (card_id,))
|
| 1039 |
if not card:
|
|
|
|
| 1125 |
|
| 1126 |
@app.patch("/cards/purchases/{purchase_id}", response_model=dict)
|
| 1127 |
def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
|
|
|
|
| 1128 |
if payload.status not in ("pending", "paid"):
|
| 1129 |
raise HTTPException(status_code=422, detail="status inválido.")
|
| 1130 |
|
|
|
|
| 1196 |
|
| 1197 |
@app.post("/cards/pay-invoice")
|
| 1198 |
def pay_invoice(payload: PayInvoiceIn):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
parse_ym(payload.invoice_ym)
|
| 1200 |
parse_date_yyyy_mm_dd(payload.pay_date)
|
| 1201 |
|
|
|
|
| 1222 |
if total <= 0:
|
| 1223 |
return {"ok": True, "message": "Nada pendente para pagar nesta fatura.", "paid_total": 0.0}
|
| 1224 |
|
|
|
|
| 1225 |
conn.execute(
|
| 1226 |
"""
|
| 1227 |
UPDATE card_purchases
|
|
|
|
| 1231 |
(now_iso(), payload.card_id, payload.invoice_ym),
|
| 1232 |
)
|
| 1233 |
|
|
|
|
| 1234 |
desc = f"Pagamento fatura {card['name']} ({payload.invoice_ym})"
|
| 1235 |
conn.execute(
|
| 1236 |
"""
|
|
|
|
| 1250 |
# ============================================================
|
| 1251 |
@app.get("/charts/card/categories")
|
| 1252 |
def chart_card_categories(card_id: int, invoice_ym: str):
|
|
|
|
| 1253 |
parse_ym(invoice_ym)
|
| 1254 |
|
| 1255 |
with db() as conn:
|
|
|
|
| 1272 |
|
| 1273 |
@app.get("/charts/combined/categories")
|
| 1274 |
def chart_combined_categories(year: int, month: int):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1275 |
if month < 1 or month > 12:
|
| 1276 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1277 |
ym = f"{year:04d}-{month:02d}"
|
|
|
|
| 1305 |
|
| 1306 |
@app.get("/charts/combined/timeseries")
|
| 1307 |
def chart_combined_timeseries(year: int, month: int):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1308 |
if month < 1 or month > 12:
|
| 1309 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1310 |
ym = f"{year:04d}-{month:02d}"
|
|
|
|
| 1386 |
|
| 1387 |
@app.get("/reports/export")
|
| 1388 |
def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1389 |
if month < 1 or month > 12:
|
| 1390 |
raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
|
| 1391 |
ym = f"{year:04d}-{month:02d}"
|
|
|
|
| 1483 |
headers={
|
| 1484 |
"Content-Type": "application/json",
|
| 1485 |
"Authorization": f"Bearer {GROQ_API_KEY}",
|
| 1486 |
+
"User-Agent": "FinanceAI/1.6.0",
|
| 1487 |
},
|
| 1488 |
)
|
| 1489 |
|
|
|
|
| 1509 |
answer = "Não consegui extrair a resposta do Groq. Verifique o payload retornado no servidor."
|
| 1510 |
|
| 1511 |
return {"answer_md": answer}
|
| 1512 |
+
|
| 1513 |
+
|
| 1514 |
+
# ============================================================
|
| 1515 |
+
# FORECAST (ML leve) — Ridge simples + lags (competência)
|
| 1516 |
+
# ============================================================
|
| 1517 |
+
try:
|
| 1518 |
+
import numpy as np
|
| 1519 |
+
except Exception:
|
| 1520 |
+
np = None
|
| 1521 |
+
|
| 1522 |
+
|
| 1523 |
+
def ym_from_year_month(year: int, month: int) -> str:
|
| 1524 |
+
return f"{year:04d}-{month:02d}"
|
| 1525 |
+
|
| 1526 |
+
|
| 1527 |
+
def ym_to_year_month(ym: str) -> tuple[int, int]:
|
| 1528 |
+
parse_ym(ym)
|
| 1529 |
+
y, m = ym.split("-")
|
| 1530 |
+
return int(y), int(m)
|
| 1531 |
+
|
| 1532 |
+
|
| 1533 |
+
def month_seq(start_ym: str, end_ym: str) -> list[str]:
|
| 1534 |
+
sy, sm = ym_to_year_month(start_ym)
|
| 1535 |
+
ey, em = ym_to_year_month(end_ym)
|
| 1536 |
+
out = []
|
| 1537 |
+
y, m = sy, sm
|
| 1538 |
+
while (y < ey) or (y == ey and m <= em):
|
| 1539 |
+
out.append(ym_from_year_month(y, m))
|
| 1540 |
+
y, m = add_months(y, m, 1)
|
| 1541 |
+
return out
|
| 1542 |
+
|
| 1543 |
+
|
| 1544 |
+
def get_min_max_ym(conn: Any) -> Optional[tuple[str, str]]:
|
| 1545 |
+
r1 = q_one(conn, "SELECT MIN(substr(date,1,7)) AS mn, MAX(substr(date,1,7)) AS mx FROM transactions")
|
| 1546 |
+
r2 = q_one(conn, "SELECT MIN(invoice_ym) AS mn, MAX(invoice_ym) AS mx FROM card_purchases")
|
| 1547 |
+
|
| 1548 |
+
mins = [x for x in [(r1 or {}).get("mn"), (r2 or {}).get("mn")] if x]
|
| 1549 |
+
maxs = [x for x in [(r1 or {}).get("mx"), (r2 or {}).get("mx")] if x]
|
| 1550 |
+
if not mins or not maxs:
|
| 1551 |
+
return None
|
| 1552 |
+
|
| 1553 |
+
return min(mins), max(maxs)
|
| 1554 |
+
|
| 1555 |
+
|
| 1556 |
+
def build_monthly_series_competencia(conn: Any) -> list[dict]:
|
| 1557 |
+
mm = get_min_max_ym(conn)
|
| 1558 |
+
if not mm:
|
| 1559 |
+
return []
|
| 1560 |
+
|
| 1561 |
+
start_ym, end_ym = mm
|
| 1562 |
+
yms = month_seq(start_ym, end_ym)
|
| 1563 |
+
|
| 1564 |
+
series = []
|
| 1565 |
+
for ym in yms:
|
| 1566 |
+
income = q_scalar(
|
| 1567 |
+
conn,
|
| 1568 |
+
"""
|
| 1569 |
+
SELECT SUM(amount) AS s
|
| 1570 |
+
FROM transactions
|
| 1571 |
+
WHERE substr(date,1,7)=? AND type='income'
|
| 1572 |
+
""",
|
| 1573 |
+
(ym,),
|
| 1574 |
+
default=0.0,
|
| 1575 |
+
)
|
| 1576 |
+
|
| 1577 |
+
expense_cash_real = q_scalar(
|
| 1578 |
+
conn,
|
| 1579 |
+
"""
|
| 1580 |
+
SELECT SUM(amount) AS s
|
| 1581 |
+
FROM transactions
|
| 1582 |
+
WHERE substr(date,1,7)=?
|
| 1583 |
+
AND type='expense'
|
| 1584 |
+
AND category<>?
|
| 1585 |
+
""",
|
| 1586 |
+
(ym, CATEGORY_CARD_PAYMENT),
|
| 1587 |
+
default=0.0,
|
| 1588 |
+
)
|
| 1589 |
+
|
| 1590 |
+
expense_card = q_scalar(
|
| 1591 |
+
conn,
|
| 1592 |
+
"""
|
| 1593 |
+
SELECT SUM(amount) AS s
|
| 1594 |
+
FROM card_purchases
|
| 1595 |
+
WHERE invoice_ym=?
|
| 1596 |
+
""",
|
| 1597 |
+
(ym,),
|
| 1598 |
+
default=0.0,
|
| 1599 |
+
)
|
| 1600 |
+
|
| 1601 |
+
expense_total = float(expense_cash_real) + float(expense_card)
|
| 1602 |
+
balance = float(income) - expense_total
|
| 1603 |
+
|
| 1604 |
+
series.append({
|
| 1605 |
+
"ym": ym,
|
| 1606 |
+
"income": float(income),
|
| 1607 |
+
"expense_cash_real": float(expense_cash_real),
|
| 1608 |
+
"expense_card": float(expense_card),
|
| 1609 |
+
"expense_total": float(expense_total),
|
| 1610 |
+
"balance": float(balance),
|
| 1611 |
+
})
|
| 1612 |
+
|
| 1613 |
+
return series
|
| 1614 |
+
|
| 1615 |
+
|
| 1616 |
+
def ridge_fit(X: "np.ndarray", y: "np.ndarray", alpha: float = 1.0) -> tuple["np.ndarray", float]:
|
| 1617 |
+
x_mean = X.mean(axis=0)
|
| 1618 |
+
y_mean = y.mean()
|
| 1619 |
+
|
| 1620 |
+
Xc = X - x_mean
|
| 1621 |
+
yc = y - y_mean
|
| 1622 |
+
|
| 1623 |
+
n_features = Xc.shape[1]
|
| 1624 |
+
A = Xc.T @ Xc + alpha * np.eye(n_features)
|
| 1625 |
+
b = Xc.T @ yc
|
| 1626 |
+
coef = np.linalg.solve(A, b)
|
| 1627 |
+
|
| 1628 |
+
intercept = y_mean - float(x_mean @ coef)
|
| 1629 |
+
return coef, float(intercept)
|
| 1630 |
+
|
| 1631 |
+
|
| 1632 |
+
def ridge_predict(X: "np.ndarray", coef: "np.ndarray", intercept: float) -> "np.ndarray":
|
| 1633 |
+
return X @ coef + intercept
|
| 1634 |
+
|
| 1635 |
+
|
| 1636 |
+
def make_time_feats(ym: str, t_idx: int) -> list[float]:
|
| 1637 |
+
_, m = ym_to_year_month(ym)
|
| 1638 |
+
ang = 2.0 * pi * (float(m) / 12.0)
|
| 1639 |
+
return [float(t_idx), float(m), sin(ang), cos(ang)]
|
| 1640 |
+
|
| 1641 |
+
|
| 1642 |
+
def make_supervised(series_vals: list[float], yms: list[str], lags: int = 6) -> tuple[list[list[float]], list[float], list[str]]:
|
| 1643 |
+
X, y, out_yms = [], [], []
|
| 1644 |
+
for i in range(lags, len(series_vals)):
|
| 1645 |
+
lag_feats = [series_vals[i - k] for k in range(1, lags + 1)]
|
| 1646 |
+
ma3 = sum(series_vals[i-3:i]) / 3.0 if i >= 3 else series_vals[i-1]
|
| 1647 |
+
ma6 = sum(series_vals[i-6:i]) / 6.0 if i >= 6 else ma3
|
| 1648 |
+
|
| 1649 |
+
feats = make_time_feats(yms[i], i) + lag_feats + [ma3, ma6]
|
| 1650 |
+
X.append([float(v) for v in feats])
|
| 1651 |
+
y.append(float(series_vals[i]))
|
| 1652 |
+
out_yms.append(yms[i])
|
| 1653 |
+
return X, y, out_yms
|
| 1654 |
+
|
| 1655 |
+
|
| 1656 |
+
def walk_forward_metrics(y_true: list[float], y_pred: list[float]) -> dict:
|
| 1657 |
+
if not y_true:
|
| 1658 |
+
return {"mae": None, "mape": None}
|
| 1659 |
+
abs_err = [abs(a - b) for a, b in zip(y_true, y_pred)]
|
| 1660 |
+
mae = sum(abs_err) / len(abs_err)
|
| 1661 |
+
|
| 1662 |
+
mape_vals = []
|
| 1663 |
+
for yt, yp in zip(y_true, y_pred):
|
| 1664 |
+
if abs(yt) < 1e-9:
|
| 1665 |
+
continue
|
| 1666 |
+
mape_vals.append(abs(yt - yp) / abs(yt))
|
| 1667 |
+
mape = (sum(mape_vals) / len(mape_vals)) if mape_vals else None
|
| 1668 |
+
return {"mae": float(mae), "mape": (float(mape) if mape is not None else None)}
|
| 1669 |
+
|
| 1670 |
+
|
| 1671 |
+
def train_models_competencia(conn: Any, alpha: float = 3.0, lags: int = 6) -> dict:
|
| 1672 |
+
series = build_monthly_series_competencia(conn)
|
| 1673 |
+
if len(series) < (lags + 6):
|
| 1674 |
+
raise HTTPException(status_code=422, detail="Histórico insuficiente para treinar. Recomendo >= 12 meses.")
|
| 1675 |
+
|
| 1676 |
+
if np is None:
|
| 1677 |
+
raise HTTPException(status_code=500, detail="numpy não instalado. Adicione numpy ao requirements.txt.")
|
| 1678 |
+
|
| 1679 |
+
yms = [r["ym"] for r in series]
|
| 1680 |
+
income_vals = [float(r["income"]) for r in series]
|
| 1681 |
+
expense_vals = [float(r["expense_total"]) for r in series]
|
| 1682 |
+
|
| 1683 |
+
Xi, yi, _ = make_supervised(income_vals, yms, lags=lags)
|
| 1684 |
+
Xe, ye, _ = make_supervised(expense_vals, yms, lags=lags)
|
| 1685 |
+
|
| 1686 |
+
Xi_np = np.array(Xi, dtype=float)
|
| 1687 |
+
yi_np = np.array(yi, dtype=float)
|
| 1688 |
+
Xe_np = np.array(Xe, dtype=float)
|
| 1689 |
+
ye_np = np.array(ye, dtype=float)
|
| 1690 |
+
|
| 1691 |
+
def split_train_val(X, y, n_val=3):
|
| 1692 |
+
n = len(y)
|
| 1693 |
+
n_val = min(max(1, n_val), max(1, n - 1))
|
| 1694 |
+
return (X[:-n_val], y[:-n_val], X[-n_val:], y[-n_val:])
|
| 1695 |
+
|
| 1696 |
+
Xi_tr, yi_tr, Xi_va, yi_va = split_train_val(Xi_np, yi_np, n_val=3)
|
| 1697 |
+
Xe_tr, ye_tr, Xe_va, ye_va = split_train_val(Xe_np, ye_np, n_val=3)
|
| 1698 |
+
|
| 1699 |
+
coef_i, b_i = ridge_fit(Xi_tr, yi_tr, alpha=alpha)
|
| 1700 |
+
coef_e, b_e = ridge_fit(Xe_tr, ye_tr, alpha=alpha)
|
| 1701 |
+
|
| 1702 |
+
pred_i = ridge_predict(Xi_va, coef_i, b_i).tolist()
|
| 1703 |
+
pred_e = ridge_predict(Xe_va, coef_e, b_e).tolist()
|
| 1704 |
+
|
| 1705 |
+
m_i = walk_forward_metrics(yi_va.tolist(), pred_i)
|
| 1706 |
+
m_e = walk_forward_metrics(ye_va.tolist(), pred_e)
|
| 1707 |
+
|
| 1708 |
+
res_i = (ridge_predict(Xi_tr, coef_i, b_i) - yi_tr)
|
| 1709 |
+
res_e = (ridge_predict(Xe_tr, coef_e, b_e) - ye_tr)
|
| 1710 |
+
std_i = float(np.std(res_i)) if len(res_i) > 1 else 0.0
|
| 1711 |
+
std_e = float(np.std(res_e)) if len(res_e) > 1 else 0.0
|
| 1712 |
+
|
| 1713 |
+
payload = {
|
| 1714 |
+
"basis": "competencia",
|
| 1715 |
+
"alpha": float(alpha),
|
| 1716 |
+
"lags": int(lags),
|
| 1717 |
+
"trained_at": now_iso(),
|
| 1718 |
+
"start_ym": yms[0],
|
| 1719 |
+
"end_ym": yms[-1],
|
| 1720 |
+
"history": series,
|
| 1721 |
+
"models": {
|
| 1722 |
+
"income": {"coef": coef_i.tolist(), "intercept": float(b_i), "std": std_i, "metrics": m_i},
|
| 1723 |
+
"expense_total": {"coef": coef_e.tolist(), "intercept": float(b_e), "std": std_e, "metrics": m_e},
|
| 1724 |
+
},
|
| 1725 |
+
"feature_spec": {
|
| 1726 |
+
"time_feats": ["t_idx", "month_num", "sin12", "cos12"],
|
| 1727 |
+
"lags": [f"lag_{k}" for k in range(1, lags + 1)],
|
| 1728 |
+
"ma": ["ma3", "ma6"],
|
| 1729 |
+
}
|
| 1730 |
+
}
|
| 1731 |
+
return payload
|
| 1732 |
+
|
| 1733 |
+
|
| 1734 |
+
def save_model(conn: Any, name: str, payload: dict) -> None:
|
| 1735 |
+
conn.execute(
|
| 1736 |
+
"""
|
| 1737 |
+
INSERT INTO ml_models(name, trained_at, payload_json)
|
| 1738 |
+
VALUES (?,?,?)
|
| 1739 |
+
ON CONFLICT(name) DO UPDATE SET
|
| 1740 |
+
trained_at=excluded.trained_at,
|
| 1741 |
+
payload_json=excluded.payload_json
|
| 1742 |
+
""",
|
| 1743 |
+
(name, payload.get("trained_at", now_iso()), json.dumps(payload, ensure_ascii=False)),
|
| 1744 |
+
)
|
| 1745 |
+
conn.commit()
|
| 1746 |
+
_maybe_sync(conn)
|
| 1747 |
+
|
| 1748 |
+
|
| 1749 |
+
def load_model(conn: Any, name: str) -> Optional[dict]:
|
| 1750 |
+
row = q_one(conn, "SELECT payload_json FROM ml_models WHERE name=?", (name,))
|
| 1751 |
+
if not row:
|
| 1752 |
+
return None
|
| 1753 |
+
try:
|
| 1754 |
+
return json.loads(row["payload_json"])
|
| 1755 |
+
except Exception:
|
| 1756 |
+
return None
|
| 1757 |
+
|
| 1758 |
+
|
| 1759 |
+
def forecast_next_months(payload: dict, horizon: int = 12) -> list[dict]:
|
| 1760 |
+
if np is None:
|
| 1761 |
+
raise HTTPException(status_code=500, detail="numpy não instalado. Adicione numpy ao requirements.txt.")
|
| 1762 |
+
|
| 1763 |
+
horizon = int(horizon)
|
| 1764 |
+
if horizon < 1 or horizon > 24:
|
| 1765 |
+
raise HTTPException(status_code=422, detail="horizon deve estar entre 1 e 24.")
|
| 1766 |
+
|
| 1767 |
+
hist = payload.get("history") or []
|
| 1768 |
+
if not hist:
|
| 1769 |
+
raise HTTPException(status_code=422, detail="Modelo sem histórico.")
|
| 1770 |
+
|
| 1771 |
+
lags = int(payload.get("lags", 6))
|
| 1772 |
+
mi = payload["models"]["income"]
|
| 1773 |
+
me = payload["models"]["expense_total"]
|
| 1774 |
+
|
| 1775 |
+
coef_i = np.array(mi["coef"], dtype=float)
|
| 1776 |
+
b_i = float(mi["intercept"])
|
| 1777 |
+
std_i = float(mi.get("std", 0.0))
|
| 1778 |
+
|
| 1779 |
+
coef_e = np.array(me["coef"], dtype=float)
|
| 1780 |
+
b_e = float(me["intercept"])
|
| 1781 |
+
std_e = float(me.get("std", 0.0))
|
| 1782 |
+
|
| 1783 |
+
yms = [r["ym"] for r in hist]
|
| 1784 |
+
inc_vals = [float(r["income"]) for r in hist]
|
| 1785 |
+
exp_vals = [float(r["expense_total"]) for r in hist]
|
| 1786 |
+
|
| 1787 |
+
last_ym = yms[-1]
|
| 1788 |
+
y, m = ym_to_year_month(last_ym)
|
| 1789 |
+
|
| 1790 |
+
preds = []
|
| 1791 |
+
for h in range(1, horizon + 1):
|
| 1792 |
+
fy, fm = add_months(y, m, h)
|
| 1793 |
+
fym = ym_from_year_month(fy, fm)
|
| 1794 |
+
|
| 1795 |
+
t_idx = (len(yms) - 1) + h
|
| 1796 |
+
|
| 1797 |
+
inc_lags = [inc_vals[-k] for k in range(1, lags + 1)]
|
| 1798 |
+
exp_lags = [exp_vals[-k] for k in range(1, lags + 1)]
|
| 1799 |
+
|
| 1800 |
+
inc_ma3 = sum(inc_vals[-3:]) / 3.0 if len(inc_vals) >= 3 else inc_vals[-1]
|
| 1801 |
+
inc_ma6 = sum(inc_vals[-6:]) / 6.0 if len(inc_vals) >= 6 else inc_ma3
|
| 1802 |
+
|
| 1803 |
+
exp_ma3 = sum(exp_vals[-3:]) / 3.0 if len(exp_vals) >= 3 else exp_vals[-1]
|
| 1804 |
+
exp_ma6 = sum(exp_vals[-6:]) / 6.0 if len(exp_vals) >= 6 else exp_ma3
|
| 1805 |
+
|
| 1806 |
+
Xi = np.array([make_time_feats(fym, t_idx) + inc_lags + [inc_ma3, inc_ma6]], dtype=float)
|
| 1807 |
+
Xe = np.array([make_time_feats(fym, t_idx) + exp_lags + [exp_ma3, exp_ma6]], dtype=float)
|
| 1808 |
+
|
| 1809 |
+
inc_pred = float(ridge_predict(Xi, coef_i, b_i)[0])
|
| 1810 |
+
exp_pred = float(ridge_predict(Xe, coef_e, b_e)[0])
|
| 1811 |
+
|
| 1812 |
+
inc_pred = max(0.0, inc_pred)
|
| 1813 |
+
exp_pred = max(0.0, exp_pred)
|
| 1814 |
+
|
| 1815 |
+
bal_pred = inc_pred - exp_pred
|
| 1816 |
+
|
| 1817 |
+
z = 1.28 # ~80%
|
| 1818 |
+
inc_low, inc_high = max(0.0, inc_pred - z * std_i), inc_pred + z * std_i
|
| 1819 |
+
exp_low, exp_high = max(0.0, exp_pred - z * std_e), exp_pred + z * std_e
|
| 1820 |
+
|
| 1821 |
+
preds.append({
|
| 1822 |
+
"ym": fym,
|
| 1823 |
+
"income_pred": inc_pred,
|
| 1824 |
+
"expense_pred": exp_pred,
|
| 1825 |
+
"balance_pred": bal_pred,
|
| 1826 |
+
"income_low": inc_low,
|
| 1827 |
+
"income_high": inc_high,
|
| 1828 |
+
"expense_low": exp_low,
|
| 1829 |
+
"expense_high": exp_high,
|
| 1830 |
+
})
|
| 1831 |
+
|
| 1832 |
+
inc_vals.append(inc_pred)
|
| 1833 |
+
exp_vals.append(exp_pred)
|
| 1834 |
+
|
| 1835 |
+
return preds
|
| 1836 |
+
|
| 1837 |
+
|
| 1838 |
+
# ============================================================
|
| 1839 |
+
# Forecast Endpoints
|
| 1840 |
+
# ============================================================
|
| 1841 |
+
@app.post("/forecast/train", response_model=ForecastTrainOut)
|
| 1842 |
+
def forecast_train(alpha: float = 3.0, lags: int = 6):
|
| 1843 |
+
with db() as conn:
|
| 1844 |
+
payload = train_models_competencia(conn, alpha=float(alpha), lags=int(lags))
|
| 1845 |
+
save_model(conn, "forecast_competencia_v1", payload)
|
| 1846 |
+
|
| 1847 |
+
metrics = {
|
| 1848 |
+
"income": payload["models"]["income"]["metrics"],
|
| 1849 |
+
"expense_total": payload["models"]["expense_total"]["metrics"],
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
return {
|
| 1853 |
+
"ok": True,
|
| 1854 |
+
"basis": "competencia",
|
| 1855 |
+
"trained_at": payload["trained_at"],
|
| 1856 |
+
"n_months": len(payload.get("history") or []),
|
| 1857 |
+
"start_ym": payload["start_ym"],
|
| 1858 |
+
"end_ym": payload["end_ym"],
|
| 1859 |
+
"metrics": metrics,
|
| 1860 |
+
}
|
| 1861 |
+
|
| 1862 |
+
|
| 1863 |
+
@app.get("/forecast", response_model=ForecastOut)
|
| 1864 |
+
def forecast_get(horizon: int = 12):
|
| 1865 |
+
with db() as conn:
|
| 1866 |
+
payload = load_model(conn, "forecast_competencia_v1")
|
| 1867 |
+
if not payload:
|
| 1868 |
+
raise HTTPException(status_code=404, detail="Modelo não treinado. Rode POST /forecast/train.")
|
| 1869 |
+
|
| 1870 |
+
preds = forecast_next_months(payload, horizon=int(horizon))
|
| 1871 |
+
|
| 1872 |
+
hist = payload.get("history") or []
|
| 1873 |
+
hist_tail = hist[-24:] if len(hist) > 24 else hist
|
| 1874 |
+
|
| 1875 |
+
metrics = {
|
| 1876 |
+
"income": payload["models"]["income"]["metrics"],
|
| 1877 |
+
"expense_total": payload["models"]["expense_total"]["metrics"],
|
| 1878 |
+
}
|
| 1879 |
+
|
| 1880 |
+
note = (
|
| 1881 |
+
"Projeção por competência: despesas = (caixa sem pagamento de fatura) + (compras do cartão por invoice_ym). "
|
| 1882 |
+
"Pagamento de fatura afeta caixa, mas não é despesa real e não entra na projeção de despesa."
|
| 1883 |
+
)
|
| 1884 |
+
|
| 1885 |
+
return {
|
| 1886 |
+
"ok": True,
|
| 1887 |
+
"basis": "competencia",
|
| 1888 |
+
"horizon": int(horizon),
|
| 1889 |
+
"trained_at": payload.get("trained_at"),
|
| 1890 |
+
"history": hist_tail,
|
| 1891 |
+
"predictions": preds,
|
| 1892 |
+
"metrics": metrics,
|
| 1893 |
+
"note": note,
|
| 1894 |
+
}
|