Gérer les valeurs décimales et monétaires

Le pilote mssql-python associe les données décimales, numériques et monétaires de SQL Server à Python decimal.Decimal pour des calculs financiers et scientifiques précis. SQL Server fournit les types numériques précis suivants :

Type SQL type de Python Précision Cas d’usage
decimal(p,s) decimal.Decimal 1 à 38 chiffres Calculs exacts
numeric(p,s) decimal.Decimal 1 à 38 chiffres Même que la décimale
argent decimal.Decimal 19 chiffres, 4 décimales Monnaie
smallmoney decimal.Decimal 10 chiffres, 4 décimales Petite monnaie
float float ~15 chiffres Approximatif
real float ~7 chiffres Approximatif

Type décimal Python

Recevoir des valeurs décimales

Les colonnes décimales retournent les objets Python decimal.Decimal :

from decimal import Decimal
import mssql_python

conn = mssql_python.connect(connection_string)
cursor = conn.cursor()

cursor.execute(
    "SELECT SubTotal, TaxAmt, TotalDue "
    "FROM Sales.SalesOrderHeader WHERE SalesOrderID = 43659"
)
row = cursor.fetchone()

print(type(row.SubTotal))  # <class 'decimal.Decimal'>
print(row.SubTotal)        # Decimal('20565.6206')
print(row.TaxAmt)          # Decimal('1971.5149')
print(row.TotalDue)        # Decimal('23153.2339')

Envoyer des valeurs décimales

Transmettez des objets Decimal pour une insertion précise :

from decimal import Decimal

cursor.execute("""
    CREATE TABLE #ProductDemo (
        Name NVARCHAR(50),
        Price DECIMAL(10,2),
        Cost DECIMAL(10,2)
    )
""")
cursor.execute("""
    INSERT INTO #ProductDemo (Name, Price, Cost)
    VALUES (%(name)s, %(price)s, %(cost)s)
""", {
    "name": "Widget",
    "price": Decimal("199.99"),
    "cost": Decimal("87.50")
})
conn.commit()

Éviter le flottement pour les données financières

Les types de flotteurs présentent des problèmes de précision qui les rendent inadaptés aux calculs financiers. Utilisez Decimal toujours pour obtenir des résultats précis :

from decimal import Decimal

# Bad: float has precision issues
price = 0.1 + 0.2  # 0.30000000000000004

# Good: Decimal is exact
price = Decimal("0.1") + Decimal("0.2")  # Decimal('0.3')

# Use string constructor for literal values
correct = Decimal("19.99")  # Exact
avoid = Decimal(19.99)      # Might introduce float imprecision

Type d’argent

Utiliser des colonnes monétaires

Récupérer et mettre à jour les colonnes d’argent de SQL Server en utilisant des objets décimaux Python :

from decimal import Decimal

# Create and populate a temp table with a money column
cursor.execute("""
    CREATE TABLE #Accounts (
        ID INT,
        AccountID NVARCHAR(20),
        Balance MONEY
    )
""")
cursor.execute(
    "INSERT INTO #Accounts (ID, AccountID, Balance) VALUES (1, %(acct)s, %(bal)s)",
    {"acct": "ACCT-001", "bal": Decimal("1234.5678")}
)
conn.commit()

cursor.execute("SELECT AccountID, Balance FROM #Accounts WHERE ID = 1")
row = cursor.fetchone()

print(row.Balance)         # Decimal('1234.5678')
print(type(row.Balance))   # <class 'decimal.Decimal'>

# Money arithmetic
cursor.execute("""
    UPDATE #Accounts 
    SET Balance = Balance + %(amount)s 
    WHERE ID = %(id)s
""", {"amount": Decimal("100.00"), "id": 1})
conn.commit()

Formatage de l’argent

Formatez les valeurs décimales comme des chaînes de monnaie avec des symboles et des séparateurs de virgules :

from decimal import Decimal

def format_currency(value: Decimal, symbol: str = "$") -> str:
    """Format decimal as currency string."""
    return f"{symbol}{value:,.2f}"

cursor.execute("SELECT TotalDue FROM Sales.SalesOrderHeader")
for row in cursor:
    print(format_currency(row.TotalDue))  # $1,234.56

Précision décimale et échelle

Comprendre la précision et l’échelle

  • Précision (p) : nombre total de chiffres
  • Échelle(s) : Nombre de chiffres après la virgule décimale

Par exemple, decimal(10, 2) peut stocker des valeurs de -99999999,99 à 99999999,99, tandis qu’il decimal(5, 4) peut stocker des valeurs de -9,9999 à 9,9999.

Spécifier la précision dans les paramètres

Le pilote mssql-python déduit automatiquement la précision et l’échelle à partir des valeurs décimales :

from decimal import Decimal

# Create a temp table for the measurement value
cursor.execute("CREATE TABLE #Measurements (Value DECIMAL(18,6))")

# The driver infers precision from the Decimal value
value = Decimal("123.456789")
cursor.execute("INSERT INTO #Measurements (Value) VALUES (%(v)s)", {"v": value})
conn.commit()

Arrondi contrôlé

Utilisez la quantize() méthode pour arrondir les valeurs décimales à une échelle spécifique :

from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN

price = Decimal("19.999")

# Round to 2 decimal places
rounded = price.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(rounded)  # Decimal('20.00')

# Round down (truncate)
truncated = price.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
print(truncated)  # Decimal('19.99')

Calculs financiers

Arithmétique de sécurité

Effectuez des calculs financiers en utilisant décimal avec un arrondi approprié :

from decimal import Decimal, ROUND_HALF_UP

def calculate_total(price: Decimal, quantity: int, tax_rate: Decimal) -> Decimal:
    """Calculate order total with tax."""
    subtotal = price * quantity
    tax = (subtotal * tax_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    return subtotal + tax

cursor.execute("SELECT ListPrice FROM Production.Product WHERE ProductID = 1")
price = cursor.fetchval()

total = calculate_total(
    price=price,
    quantity=3,
    tax_rate=Decimal("0.0825")  # 8.25% tax
)

cursor.execute("""
    CREATE TABLE #OrderCalc (
        ProductID INT, Quantity INT, Total DECIMAL(10,2), Tax DECIMAL(10,2)
    )
""")
cursor.execute("""
    INSERT INTO #OrderCalc (ProductID, Quantity, Total, Tax)
    VALUES (%(prod)s, %(qty)s, %(total)s, %(tax)s)
""", {"prod": 1, "qty": 3, "total": total, "tax": total * Decimal("0.0825")})

Calculs de pourcentages

Calculez les remises et ajustements basés sur le pourcentage :

from decimal import Decimal

def calculate_discount(price: Decimal, discount_percent: Decimal) -> Decimal:
    """Calculate discounted price."""
    discount = (price * discount_percent / 100).quantize(Decimal("0.01"))
    return price - discount

original = Decimal("99.99")
discounted = calculate_discount(original, Decimal("15"))  # 15% off
print(f"Original: ${original}, After discount: ${discounted}")

Montants partagés

Divisez un montant total équitablement entre plusieurs parties, en gérant les restes :

from decimal import Decimal, ROUND_DOWN

def split_amount(total: Decimal, ways: int) -> list[Decimal]:
    """Split amount evenly, handling remainder."""
    per_person = (total / ways).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
    remainder = total - (per_person * ways)
    
    amounts = [per_person] * ways
    # Add remainder to first person
    amounts[0] += remainder
    return amounts

total = Decimal("100.00")
split = split_amount(total, 3)
print(split)  # [Decimal('33.34'), Decimal('33.33'), Decimal('33.33')]
print(sum(split))  # Decimal('100.00') - always equals original

Opérations d’agrégation

SUM et AVG avec décimales

Les fonctions d’agrégation de SQL Server retournent les types décimaux pour la précision :

# SUM preserves decimal type
cursor.execute("SELECT SUM(TotalDue) AS TotalSales FROM Sales.SalesOrderHeader")
total_sales = cursor.fetchval()
print(type(total_sales))  # <class 'decimal.Decimal'>

# AVG might return more decimal places
cursor.execute("SELECT AVG(ListPrice) AS AvgPrice FROM Production.Product")
avg_price = cursor.fetchval()
# Round to desired precision
avg_price = avg_price.quantize(Decimal("0.01"))

Gérer NULL dans les agrégations

Les fonctions d’agrégation de SQL Server peuvent renvoyer None lorsqu’aucune ligne ne correspond à la requête :

cursor.execute("SELECT SUM(TotalDue) FROM Sales.SalesOrderHeader WHERE CustomerID = 0")
total_discount = cursor.fetchval()

# SUM returns NULL if no rows match
if total_discount is None:
    total_discount = Decimal("0.00")

Convertisseurs de sortie pour les nombres décimaux

Les convertisseurs de sortie utilisent le type Python indiqué dans cursor.description comme clé, et non l’entier du type SQL ODBC. Enregistrez le convertisseur avec decimal.Decimal pour qu’il fonctionne pour decimal, numeric, money, et smallmoney les colonnes, qui correspondent tous à decimal.Decimal.

Convertir en flotteur (quand la précision n’est pas critique)

Pour la compatibilité avec les bibliothèques qui attendent des valeurs de flottement, définissez un convertisseur de sortie :

import mssql_python
from decimal import Decimal

def decimal_to_float(value):
    """Convert Decimal to float."""
    if value is None:
        return None
    return float(value)  # value is already a Decimal object

conn = mssql_python.connect(connection_string)

# Convert decimals to float for compatibility with libraries that expect float
conn.add_output_converter(Decimal, decimal_to_float)

cursor = conn.cursor()
cursor.execute("SELECT ListPrice FROM Production.Product WHERE ProductID = 1")
row = cursor.fetchone()
print(type(row.ListPrice))  # <class 'float'>

Convertisseur de format monétaire personnalisé

Définissez un convertisseur de sortie personnalisé pour formater automatiquement les valeurs décimales en chaînes de monnaie :

from decimal import Decimal

def money_to_string(value):
    """Convert Decimal to formatted string."""
    if value is None:
        return None
    return f"${value:,.2f}"  # value is already a Decimal object

conn.add_output_converter(Decimal, money_to_string)

cursor = conn.cursor()
cursor.execute("CREATE TABLE #Accounts (Balance DECIMAL(19,4))")
cursor.execute(
    "INSERT INTO #Accounts (Balance) VALUES (%(bal)s)",
    {"bal": Decimal("1234.56")}
)
conn.commit()

cursor.execute("SELECT Balance FROM #Accounts")
for row in cursor:
    print(row.Balance)  # "$1,234.56"

Modèles courants

Conversion de devise

Convertir les montants monétaires entre devises en utilisant les taux de change :

from decimal import Decimal

def convert_currency(amount: Decimal, rate: Decimal) -> Decimal:
    """Convert currency at given exchange rate."""
    return (amount * rate).quantize(Decimal("0.01"))

usd_amount = Decimal("100.00")
eur_rate = Decimal("0.92")
eur_amount = convert_currency(usd_amount, eur_rate)
print(f"${usd_amount} = €{eur_amount}")

Mise à jour du solde à l’aide des transactions

Utilisez des transactions avec des indices de verrouillage appropriés pour garantir les transferts de fonds atomiques et éviter les mises à jour perdues :

from decimal import Decimal

def transfer_funds(conn, from_account: int, to_account: int, amount: Decimal):
    """Transfer funds between accounts atomically."""
    cursor = conn.cursor()
    conn.autocommit = False
    
    try:
        balances = {}
        for account_id in sorted((from_account, to_account)):
            cursor.execute(
                "SELECT Balance FROM #Accounts WITH (UPDLOCK) WHERE ID = %(id)s",
                {"id": account_id}
            )
            row = cursor.fetchone()
            if row is None:
                raise ValueError(f"Account {account_id} not found")
            balances[account_id] = row.Balance
        
        if balances[from_account] < amount:
            raise ValueError("Insufficient funds")
        
        # Debit source
        cursor.execute("""
            UPDATE #Accounts SET Balance = Balance - %(amount)s
            WHERE ID = %(id)s
        """, {"amount": amount, "id": from_account})
        
        # Credit destination
        cursor.execute("""
            UPDATE #Accounts SET Balance = Balance + %(amount)s
            WHERE ID = %(id)s
        """, {"amount": amount, "id": to_account})
        
        conn.commit()
        
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.autocommit = True

# Set up demo accounts
cursor = conn.cursor()
cursor.execute("CREATE TABLE #Accounts (ID INT, Balance MONEY)")
cursor.executemany(
    "INSERT INTO #Accounts (ID, Balance) VALUES (%(id)s, %(bal)s)",
    [{"id": 1, "bal": Decimal("1000.00")}, {"id": 2, "bal": Decimal("500.00")}]
)
conn.commit()

# Usage
transfer_funds(conn, from_account=1, to_account=2, amount=Decimal("500.00"))

Le transfert verrouille d’emblée les deux lignes, en commençant par l’ID le plus bas, en émettant un SELECT ... WITH (UPDLOCK) distinct pour chaque compte dans l’ordre de tri. UPDLOCK Empêche le schéma de mises à jour perdues où deux transactions lisent le même solde et appliquent des mises à jour basées sur des données obsolètes. Émettre les verrous sous forme d’instructions distinctes dans un ordre trié est ce qui garantit l’ordre d’acquisition, empêchant ainsi des transferts opposés entre les deux mêmes comptes d’acquérir les verrous dans l’ordre inverse et de provoquer un interblocage.

Insertion en masse avec des nombres décimaux

Insérer plusieurs lignes avec des valeurs décimales en utilisant executemany():

from decimal import Decimal

cursor.execute("""
    CREATE TABLE #BulkProducts (
        Name NVARCHAR(50),
        Price DECIMAL(10,2),
        Cost DECIMAL(10,2)
    )
""")

products = [
    {"name": "Widget A", "price": Decimal("19.99"), "cost": Decimal("8.50")},
    {"name": "Widget B", "price": Decimal("29.99"), "cost": Decimal("12.75")},
    {"name": "Widget C", "price": Decimal("39.99"), "cost": Decimal("18.00")},
]

cursor.executemany("""
    INSERT INTO #BulkProducts (Name, Price, Cost)
    VALUES (%(name)s, %(price)s, %(cost)s)
""", products)
conn.commit()

Bonnes pratiques

Utilisez toujours Decimal pour les calculs financiers

N’utilisez jamais le float pour les calculs financiers ; Utilisez toujours le type décimal pour la précision :

# Wrong: float precision issues
price = 0.10
quantity = 3
total = price * quantity  # 0.30000000000000004

# Correct: Decimal is exact
price = Decimal("0.10")
quantity = 3
total = price * quantity  # Decimal('0.30')

Utilisez des constructeurs de chaînes

Construisez des valeurs décimales à partir de chaînes pour éviter l’imprécision de flottement :

# Good: exact value
amount = Decimal("123.45")

# Risky: might inherit float imprecision
amount = Decimal(123.45)  # Could be 123.4500000000000028...

Toujours arrondir avant d’afficher ou de stocker

Arrondir les valeurs décimales à l’échelle appropriée avant d’afficher ou de les faire persister dans la base de données :

from decimal import Decimal, ROUND_HALF_UP

calculated = Decimal("123.456789")
display = calculated.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

Définir le contexte décimal si nécessaire

La précision décimale du contexte affecte les résultats des calculs, pas les valeurs analysées à partir de chaînes ou lues dans la base de données. Une requête retourne toujours une money valeur ou decimal avec son échelle complète stockée, indépendamment de getcontext().prec. Le contexte ne prend effet que lorsque vous calculez avec. Pour voir la précision prendre effet, effectuez une opération telle que la division.

from decimal import Decimal, getcontext

# A money value read from SQL Server arrives at its full stored scale,
# regardless of the context precision.
cursor.execute(
    "SELECT SubTotal FROM Sales.SalesOrderHeader WHERE SalesOrderID = 43659"
)
subtotal = cursor.fetchone().SubTotal
print(subtotal)  # Decimal('20565.6206')

# The context precision governs the calculation, not the fetched value.
# Split the subtotal into three equal installments.
getcontext().prec = 10
print(subtotal / 3)  # 6855.206867 (10 significant digits)

getcontext().prec = 28  # Reset to default
print(subtotal / 3)  # 6855.206866666666666666666667 (28 significant digits)