ES
ERVIS SEFERI
Finance · Business · Technologie
← Retour · Je partage mes connaissances
IA & Automatisation · SQL et Python pour la finance

Le solde que le total ne raconte pas

Un solde de banque ligne après ligne, le plus gros mouvement de chaque journal ex aequo compris, l'écart au mois précédent : trois fonctions de fenêtrage SQL.

Dans le cadre de la rubrique « Je partage mes connaissances », je vous propose de reprendre le grand livre du premier semestre 2026 pour lui poser trois questions qu'un GROUP BY ne sait pas traiter : comment le solde de la banque a-t-il évolué ligne après ligne, quel est le plus gros mouvement de chaque journal, et de combien un mois s'écarte-t-il du précédent. Les trois réponses tiennent dans une même famille de fonctions, les fonctions de fenêtrage, et dans DB Browser for SQLite.

À gauche, 86 lignes du compte banque fusionnées par GROUP BY en 6 totaux mensuels ; à droite, les mêmes 86 lignes conservées avec une colonne de solde cumulé qui finit à -79 570,00
Un GROUP BY résume et fait disparaître les lignes ; une fonction de fenêtrage calcule sur un ensemble de lignes et les garde toutes.

Les fichiers du cas sont ceux de la série : la table ecritures, 173 lignes, chargée comme dans le premier article. Une base neuve suffit, aucune autre table n'est nécessaire ici.

Ce que le total ne dit pas

Le compte 512000 (banque) clôture le semestre à -79 570,00. Ce chiffre est juste, et il ne dit presque rien. Il ne dit pas si la trésorerie s'est dégradée d'un coup ou régulièrement, ni quel mois a pesé.

Le réflexe Excel, c'est une colonne de solde où chaque cellule reprend la précédente plus le mouvement. En SQL, le réflexe est un GROUP BY, qui fait l'inverse de ce qu'on cherche : il fusionne les 86 lignes en quelques totaux. La fonction de fenêtrage fait ce que fait la colonne Excel. Elle calcule sur un ensemble de lignes, la « fenêtre », et rend une valeur sur chaque ligne sans en supprimer aucune.

Un deuxième outil rend les requêtes lisibles : la CTE, ou clause WITH. Elle donne un nom à une étape intermédiaire. On isole d'abord les mouvements de banque, on calcule ensuite, et chaque bloc se relit seul.

Le solde, ligne après ligne

Le cas

Afficher, dans l'ordre chronologique, le solde du compte 512000 tel qu'il était après chaque mouvement, sans perdre aucune des 86 lignes.

La démarche

  1. Onglet Exécuter le SQL. Écrire la CTE mouvements_banque, qui ne garde que le compte 512000 et calcule un montant signé (débit moins crédit).
  2. Sur cette CTE, ajouter SUM(montant) OVER (...) : la somme se cumule au lieu de regrouper.
  3. Trier la fenêtre par date_ecriture, id. Toutes les lignes d'un mois portent la même date de fin de mois : sans second critère, l'ordre à l'intérieur d'une date n'est pas garanti. Le rowid de l'import donne un ordre stable.
  4. Écrire une fois la bordure ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW : « tout depuis le début jusqu'à cette ligne ». C'est le comportement implicite d'un ORDER BY seul ; l'écrire, c'est savoir ce que le moteur fait.
  5. F5.
WITH mouvements_banque AS (
  SELECT rowid AS id, date_ecriture, journal, libelle,
         ROUND(debit - credit, 2) AS montant
  FROM ecritures
  WHERE compte = '512000'
)
SELECT date_ecriture, journal, libelle, montant,
       ROUND(SUM(montant) OVER (ORDER BY date_ecriture, id
             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 2) AS solde_progressif
FROM mouvements_banque
ORDER BY date_ecriture, id;

Ce que vous devez obtenir

86 lignes. La dernière ligne de chaque date donne le solde de fin de mois :

Fin de moisSolde progressif
31/01/2026-12 380,00
28/02/2026-26 370,00
31/03/2026-38 160,00
30/04/2026-54 950,00
31/05/2026-66 640,00
30/06/2026-79 570,00

Un cumul complet retombe toujours sur le total simple. Si votre dernière ligne ne donne pas -79 570,00, ne cherchez pas dans les données : c'est presque toujours le tri de la fenêtre qui est faux.

Une précision qui évite une mauvaise lecture. Dans ce fichier, tous les mouvements d'un mois sont datés du dernier jour du mois. Les soldes intermédiaires d'une même date dépendent donc de l'ordre de saisie, pas de l'ordre réel des opérations. Seule la dernière ligne de chaque date a un sens de trésorerie.

Le plus gros mouvement de chaque journal, ex aequo compris

Le cas

Pour chacun des cinq journaux qui mouvementent la banque, sortir le mouvement le plus important en valeur absolue. Quand plusieurs mouvements sont exactement à égalité, les montrer tous.

La démarche

  1. Reprendre la CTE des mouvements de banque.
  2. PARTITION BY journal découpe la fenêtre en cinq groupes indépendants ; ORDER BY ABS(montant) DESC place le plus gros mouvement en tête de chaque groupe.
  3. Choisir RANK(), pas ROW_NUMBER(). RANK() donne le même rang à des valeurs égales. ROW_NUMBER() numérote 1, 2, 3 sans jamais répéter, et tranche donc les égalités arbitrairement.
  4. Filtrer sur le rang dans une seconde CTE. Un rang calculé par une fonction de fenêtrage n'existe pas encore au moment du WHERE de sa propre requête.
  5. F5.
WITH mouvements_banque AS (
  SELECT date_ecriture, journal, libelle, ROUND(debit - credit, 2) AS montant
  FROM ecritures
  WHERE compte = '512000'
),
classement AS (
  SELECT *, RANK() OVER (PARTITION BY journal ORDER BY ABS(montant) DESC) AS rang
  FROM mouvements_banque
)
SELECT journal, date_ecriture, libelle, montant, rang
FROM classement
WHERE rang = 1
ORDER BY journal, date_ecriture;

Ce que vous devez obtenir

12 lignes, pas 5. Le nombre est la réponse :

JournalLignes au rang 1Montant
ACH1 : fournisseurs, 31/03/202615 200,00
VTE1 : ventes de produits finis, 30/06/202644 600,00
PAIE3 ex aequo : rémunérations d'avril, mai et juin-23 100,00 chacune
BQ1 : charges d'intérêts, 30/06/2026-950,00
OD6 ex aequo : les dotations aux amortissements des six mois-2 100,00 chacune
Les six dotations OD à -2 100,00 classées deux fois : RANK leur donne à toutes le rang 1, ROW_NUMBER les numérote de 1 à 6 et n'en garde qu'une au filtre rang = 1
Sur six montants identiques, ROW_NUMBER garde une ligne choisie sans raison ; RANK montre que la charge est constante, ce qui est une information.

Le journal OD ne porte que des dotations identiques, mois après mois. Avec ROW_NUMBER(), la requête aurait rendu une seule ligne d'OD et caché que la charge ne bouge pas. La question « quel est le plus gros mouvement » admettait six bonnes réponses : la fonction devait pouvoir les rendre.

Ce mois-ci contre le mois dernier, sur la même ligne

Le cas

Le compte 641000 (rémunérations du personnel) bouge une fois sur le semestre. Afficher pour chaque mois le montant, celui du mois précédent et la variation, sans joindre la table à elle-même.

La démarche

  1. Une CTE isole le compte 641000.
  2. LAG(montant) OVER (ORDER BY date_ecriture) lit la valeur de la ligne précédente et la pose sur la ligne courante. C'est ce qui remplace l'auto-jointure « mois = mois précédent ».
  3. La variation est une soustraction entre la colonne et son LAG.
  4. Le premier mois n'a pas de prédécesseur : LAG rend NULL, la variation aussi. C'est le bon comportement.
  5. F5.
WITH remuneration AS (
  SELECT date_ecriture, ROUND(debit - credit, 2) AS montant
  FROM ecritures
  WHERE compte = '641000'
)
SELECT date_ecriture, montant,
       LAG(montant) OVER (ORDER BY date_ecriture) AS montant_mois_precedent,
       ROUND(montant - LAG(montant) OVER (ORDER BY date_ecriture), 2) AS variation
FROM remuneration
ORDER BY date_ecriture;

Ce que vous devez obtenir

MoisMontantMois précédentVariation
31/01/202622 500,00(vide)(vide)
28/02/202622 500,0022 500,000,00
31/03/202622 500,0022 500,000,00
30/04/202623 100,0022 500,00600,00
31/05/202623 100,0023 100,000,00
30/06/202623 100,0023 100,000,00

Une seule variation, +600,00 en avril, et elle tient ensuite : une hausse durable, pas un accident. Un contrôle mensuel doit faire remonter ce signal sans relire six lignes à l'œil.

La même mécanique s'applique au flux net de la banque, à condition de regrouper d'abord par mois dans une CTE, puis de comparer. Les deux outils s'enchaînent : le GROUP BY résume, la fenêtre compare.

WITH flux AS (
  SELECT substr(date_ecriture, 1, 7) AS mois,
         ROUND(SUM(debit - credit), 2) AS flux_net
  FROM ecritures
  WHERE compte = '512000'
  GROUP BY mois
)
SELECT mois, flux_net,
       LAG(flux_net) OVER (ORDER BY mois) AS mois_precedent,
       ROUND(flux_net - LAG(flux_net) OVER (ORDER BY mois), 2) AS variation
FROM flux
ORDER BY mois;
MoisFlux net banqueVariation
2026-01-12 380,00(vide)
2026-02-13 990,00-1 610,00
2026-03-11 790,002 200,00
2026-04-16 790,00-5 000,00
2026-05-11 690,005 100,00
2026-06-12 930,00-1 240,00

Avril décroche de 5 000,00 sur mars. Plusieurs postes y contribuent, mais 3 200,00 n'ont jamais quitté la banque : c'est le loyer d'avril saisi deux fois, que le contrôle des doublons avait déjà isolé. Deux méthodes différentes pointent le même mois. Le défaut reste signalé, il n'est pas effacé en silence dans la requête.

Les erreurs qui coûtent une soirée

  • Filtrer un rang dans la même requête. WHERE rang = 1 juste après RANK() est refusé : la fenêtre se calcule après le WHERE. Une CTE au-dessus règle la question.
  • Prendre ROW_NUMBER quand la question admet des égalités. Le résultat a l'air propre et il est incomplet.
  • Trier la fenêtre sur la date seule. Avec des dates répétées, deux exécutions peuvent donner deux cumuls intermédiaires différents. Ajoutez toujours un critère qui départage.
  • « Corriger » le NULL du premier mois par un zéro. Une variation de 0,00 en janvier affirmerait que décembre valait autant ; le vide dit la vérité : il n'y a pas de mois précédent dans le fichier.

La suite de la série rapproche ce grand livre du relevé bancaire du même semestre, sans clé commune entre les deux fichiers : le doublon que le rapprochement ne voyait pas. Pour revoir comment un GROUP BY localise un écart au journal et à la date : équilibré et pourtant faux.

Versions : article rédigé d'après DB Browser for SQLite 3.13.1, la version proposée au téléchargement sur sqlitebrowser.org (interface en français). Les fonctions de fenêtrage existent dans SQLite depuis la version 3.25.0 (septembre 2018) ; les requêtes sont du SQL standard et s'exécutent à l'identique dans PostgreSQL ou DuckDB.