Skip to content

SYSCOHADA & ratios ​

100 % deterministic ("Zero LLM Math"). Three implementations agree on the same algebra:

EnginePathUsed for
Python (worker)locus-workers/table_extractor/parser.py, ratio_engine/calculator.pyDecimal math, ROUND_HALF_UP, regression pack
TypeScriptlocus-web/src/engine/ratio_calculator.ts (computeSyscohadaRatios) + components/ratio-cockpit/DeterministicRatioCockpit.tsxclient cockpit, copilot tool get_syscohada_ratios
C# (SoR)locus-backend/Services/DossierAnalyticsService.cspersistence of FinancialStatements / ExtractedCells / RatioSnapshots during analysis

1. Extract ​

  • Taxonomy line codes (EBE, cash, LTD, STD, …) from PDF text / worker extract.
  • Invariants: Actif ≡ Passif (within tolerance); VA − Personnel ≡ EBE style checks in analytics.
  • Persistence: FinancialStatements + ExtractedCells (+ optional SourceCitationId).
mermaid
flowchart LR
  OCR[ExtractedPages text + WordsJson] --> W[POST /api/v1/extract-syscohada]
  W -->|"≥ 3 values"| Cells
  W -.->|offline or < 3 values| Local[ExtractCellsFromText in-process]
  Local --> Cells[cells: line code → value]
  Cells --> Cite[MatchLineCitations → Citations]
  Cells --> FS[FinancialStatement + ExtractedCells]
  Cells --> RS[RatioSnapshot]
  FS --> Rec{|Actif − Passif| ≤ tolerance}
  Rec -->|yes| OK[RECONCILED]
  Rec -->|no| UN[UNRECONCILED → covenantCertificationBlocked]

Regex taxonomy (SYSCOHADA_TAXONOMY) ​

Multi-pattern regex dictionary matching accounting items across standard variations (technical documentation §4.1):

Canonical keyStatementRepresentative patterns
revenueSIGchiffre\s+d['’]affaires, ventes\s+de\s+marchandises, produits\s+d['’]exploitation
value_addedSIGvaleur\s+ajout[eé]e, v\.a\.
personnel_expensesSIGcharges\s+de\s+personnel, frais\s+de\s+personnel, salaires\s+et\s+traitements
ebitda (EBE)SIGexc[eé]dent\s+brut\s+d['’]exploitation, e\.b\.e\., ebitda
depreciation_amortizationSIGdotations\s+aux\s+amortissements, …
ebit (REX)SIGr[eé]sultat\s+d['’]exploitation, r\.e\.x\., ebit
financial_income / financial_expensesSIGproduits\s+financiers / charges\s+financi[eè]res, frais\s+financiers
net_ordinary_income (RAO), exceptional_income (RHAO), corporate_tax, net_incomeSIGRAO / RHAO / i\.s\. / résultat net
fixed_assets, inventories, trade_receivables, other_current_assetsBilan Actifactif\s+immobilis[eé], stocks, cr[eé]ances\s+clients, autres\s+cr[eé]ances
cash_and_equivalents (Trésorerie Actif)Bilan Actiftr[eé]sorerie\s+actif, banques,\s+ch[eè]ques\s+postaux,\s+caisse, disponibilit[eé]s
total_assetsBilan Actiftotal\s+actif, total\s+g[eé]n[eé]ral\s+actif
equityBilan Passifcapitaux\s+propres, capital\s+social
long_term_financial_debt (Dettes Financières LMT)Bilan Passifdettes\s+financi[eè]res\s+et\s+assimil[eé]es, emprunts\s+obligataires, emprunts\s+aupr[eè]s\s+des\s+[eé]tablissements\s+de\s+cr[eé]dit
short_term_financial_debt (Trésorerie Passif)Bilan Passifd[eé]couverts\s+et\s+concours\s+bancaires, tr[eé]sorerie\s+passif, emprunts\s+[aà]\s+court\s+terme
trade_payables, tax_and_social_payables, other_current_payablesBilan Passifdettes\s+fournisseurs, dettes\s+fiscales\s+et\s+sociales, autres\s+dettes\s+circulantes
total_liabilitiesBilan Passiftotal\s+passif, total\s+g[eé]n[eé]ral\s+passif
capex, scheduled_principal_repaymentSIGacquisitions\s+d'immobilisations, remboursement\s+du\s+principal (SoR taxonomy)

The SoR variant (DossierAnalyticsService.Taxonomy) does the same with normalized substring matches and takes the last French-formatted number on the matching line; zero values are ignored.

2. Invariants ​

Two non-negotiable structural equalities (technical documentation §4.1):

[ \Delta_{\text{Balance}} = \lvert \text{Total Actif} - \text{Total Passif} \rvert \equiv 0 ]

[ \Delta_{\text{SIG}} = \lvert (\text{Valeur Ajoutée} - \text{Charges de Personnel}) - \text{EBE} \rvert \equiv 0 ]

If (\Delta > 0) the parser records an accounting discrepancy and flags the line items. A mismatch marks the statement UNRECONCILED and blocks covenant certification. Analyst adjustments capture the prior value, rationale and user id and recalculate dependent ratios.

Tolerances by layer ​

LayerTestTolerance
Python compute_period_ratiosis_balance_reconciled, is_ebe_reconciled|Δ| < 0.01 (currency units)
TypeScript computeSyscohadaRatiossame|Δ| < 1.0
SoR UpsertFinancialStatementAsyncReconciliationStateRECONCILED iff Δ ≤ max(1 000 000, 0.5 % × totalAssets) (XAF), else UNRECONCILED
SoR GET /api/deals/{id}/reconciliationcovenantCertificationBlockedstatements with |Actif − Passif| ≥ 0.01 flagged UNRECONCILED (technical doc §10.16)

AUDCIF 2017 Art. 17 requires both totals to be equal at each closing date; an unreconciled balance sheet is never accepted as-is (see OCR override SOP).

Worked proof (SEEG 2023, reference fixture) ​

CheckComputationResult
Total Actif142.5 + 18.2 + 42.8 + 9.4 + 14.8 (B FCFA)227.70 B
Total Passif58.4 + 72.5 + 30.2 + 38.6 + 16.8 + 11.2227.70 B
(\Delta_{\text{Balance}})(\lvert 227.7 - 227.7 \rvert)0 FCFA
EBE (SIG)62.35 (VA) − 27.50 (personnel)34.85 B = reported EBE, (\Delta_{\text{SIG}} = 0)
Net debt(72.5 + 30.2) − 14.887.90 B
Levier net87.90 / 34.852.52× (ceiling 3.50×, headroom 0.98×)

SEEG is a test-reference dataset; fresh tenants never see it.

3. Ratio definitions ​

Core formulas (technical documentation §4.2; same in Python and TS):

[ \text{Gross Debt} = \text{Dettes Financières LMT} + \text{Dettes CT} \qquad \text{Net Debt} = \text{Gross Debt} - \text{Trésorerie Actif} ]

[ \text{Leverage} = \frac{\text{Net Debt}}{\text{EBE}} ;[\le 3.50\times] \qquad \text{ICR} = \frac{\text{REX (EBIT)}}{\text{Charges Financières}} ;[\ge 2.50\times] ]

[ \text{DSCR} = \frac{\text{CFADS}}{\text{Remboursement Principal} + \text{Intérêts Financiers}} ]

[ \text{FRNG} = (\text{Capitaux Propres} + \text{Dettes LMT}) - \text{Actif Immobilisé} \qquad \text{BFR} = (\text{Stocks} + \text{Créances Clients} + \text{Autres Créances}) - (\text{Fournisseurs} + \text{Fiscal/Social} + \text{Autres Dettes}) ]

[ \text{Trésorerie Nette} = \text{FRNG} - \text{BFR} \qquad \text{BFR}_{\text{jours}} = \frac{\text{BFR}}{\text{Chiffre d'Affaires}} \times 365 ]

RatioCode definitionGuard (zero denominator)
Net debt / EBEnet_debt / ebitdaebitda ≤ 0 → 999
Gearingnet_debt / equityequity ≤ 0 → 0
Debt / capitalizationgross_debt / (gross_debt + equity)—
ICRebit / financial_expensesfinancial_expenses ≤ 0 → 999
EBITDA ICRebitda / financial_expensessame
DSCR(ebitda − corporate_tax − capex) / (scheduled_principal_repayment + financial_expenses)debt_service ≤ 0 → null
FRNG, BFR, net cashas above (other_current_payables included in BFR liabilities)—
BFR daysbfr / revenue × 365 (1 decimal)revenue ≤ 0 → 0
Current / quick ratio(op. current assets + cash) / (op. current liabilities + STD); quick = (receivables + cash) / …denominator ≤ 0 → 999
MarginsEBITDA / EBIT / net ÷ revenue × 100revenue ≤ 0 → 0

CFADS in code vs. narrative

The technical documentation writes CFADS as EBE − Impôts − ΔBFR − CAPEX; the shipped engines (Python, TS, SoR snapshot) currently use EBITDA − corporate tax − CAPEX (no ΔBFR term), and the SoR snapshot uses ebitda / (financial_expenses + principal). Treat narrative DSCR as indicative; the cockpit/SoR value is authoritative.

Rounding ​

round_ratio quantizes with ROUND_HALF_UP (2 places; BFR days 1 place) to avoid floating-point drift. TS uses Number(x.toFixed(n)). SoR persists Math.Round(x, 4) for leverage / DSCR / ICR and Math.Round(netDebt, 2).

Covenant evaluation matrix ​

CovenantThresholdPython workerSoR AnalysisResult
Net Debt / EBITDA≤ 3.50×BREACH (CRITICAL) above; WARNING (MAJOR) within 10 % (> 3.15×); else COMPLIANTBREACH if > 3.50, else PASS
ICR≥ 2.50×BREACH (CRITICAL) belowBREACH if 0 < ICR < 2.50
DSCR≥ 1.30× (default min_dscr)BREACH (CRITICAL) belowlimit 1.20, WARNING if 0 < DSCR < 1.20
Current ratio≥ 1.10× (threshold defined)——

Technical documentation narrative: DSCR < 1.30× → WARNING / MAJOR; the Credit Scenario Lab raises COVENANT_BREACH_ALERT when stressed DSCR < 1.20×. Some narrative screens use story floors of 1.20× versus the cockpit default 1.30× (see UI tours). Boundary tests: BREACH at 3.51×, COMPLIANT at 3.50× (tests/test_ratio_engine.py).

RatioSnapshot persistence ​

ratio_{dossierId}_{year}_{scenario} (lower-case), FormulaId = STD_SYSCOHADA_V2, NetDebtAmount, LeverageRatio, DscrValue, IcrValue, CovenantBreached = (leverage > 3.5) || (ICR < 2.5) || (DSCR < 1.2) — updated on every analysis run.

4. Real-time sensitivity stress-tester (cockpit) ​

In the frontend cockpit sliders recalculate with no network round-trip:

SliderRangeEffect
Choc Chiffre d'Affaires (\Delta\text{CA})−25 % … +25 %Adjusts revenue; EBITDA moves (1.5\times) operating leverage, EBIT (1.8\times)
Hausse des Taux / Marge0 … +400 bpsExpands interest expense → ICR degradation
Surcoût CAPEX0 … +50 %DSCR compression under construction / maintenance overruns

Copilot tools simulate_credit_stress (EBITDA shocks −10/−20/−30 %) and audit_covenants reuse these engines.

5. Override ​

POST /api/locus/deals/{id}/cells/{cellId}/override (SoR POST /api/deals/{id}/cells/{cellId}/override, permission financial:override) — keeps ReportedValue, sets accepted value + reason (min 5 characters) + audit CELL_OVERRIDE; the BFF validates with CellOverrideSchema and re-computes dependent ratios through reconciliationService.adjustCell. Full procedure and request body: OCR override SOP.

json
{ "acceptedValue": 1245600, "reason": "p.14 Total Passif misread by OCR; confirmed on stamped page" }

6. Tests ​

FileCases
tests/test_table_extractor.pytest_taxonomy_mapping, test_balance_sheet_invariants, test_sig_invariants
tests/test_ratio_engine.pytest_ratio_computation_accuracy, test_covenant_evaluator, test_rounding_and_precision
locus-web/tests/reconciliation_sor_bff.test.ts, reference_regression_pack.test.ts, sor_dossier_analytics_unit.test.tsSoR reconciliation, SEEG regression lock (2.52× vs 3.50×)

Related: Citations (R4) · Contradictions · Dossier → OCR → analysis.

Source of trust for Locus. If this conflicts with code, code + IMPLEMENTATION_TRACKER.md win.