ninarg commited on
Commit
355f477
·
verified ·
1 Parent(s): ef003ff

Financial Sankey Explorer (v5.36)

Browse files
Files changed (3) hide show
  1. README.md +53 -6
  2. app.py +288 -0
  3. requirements.txt +1 -0
README.md CHANGED
@@ -1,13 +1,60 @@
1
  ---
2
  title: Financial Sankey Explorer
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Financial Sankey Explorer
3
+ emoji: 💸
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.15.2
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: Funds-flow Sankeys from synthetic financial statements
12
+ tags:
13
+ - vynfi
14
+ - sankey
15
+ - financial-statements
16
+ - cash-flow
17
+ - synthetic-data
18
  ---
19
 
20
+ # 💸 Financial Sankey Explorer
21
+
22
+ Interactive multi-step **funds-flow** view of synthetic financial statements
23
+ produced by [`datasynth-data`](https://vynfi.com). Pick an entity, a period, and
24
+ a statement and read the money as it flows:
25
+
26
+ - **Income statement** — a left-to-right waterfall:
27
+ `Revenue → {Cost of Sales, Gross Profit} → {operating-expense sub-bands, Operating Income} → {Tax, Net Income}`.
28
+ - **Cash flow** — each line item flows into its section (Operating / Investing /
29
+ Financing), and the sections into **Net Change in Cash**.
30
+
31
+ Red bands are reductions / outflows; blue bands are retained amounts / inflows.
32
+
33
+ ## Data
34
+
35
+ The app renders the engine's native export, `financial_reporting/sankey/flows.json`
36
+ (emitted when `financial_reporting.sankey: true`). Each entry is a self-describing
37
+ `SankeyFlow`:
38
+
39
+ ```json
40
+ {
41
+ "statement_type": "income_statement",
42
+ "fiscal_year": 2024, "fiscal_period": 3,
43
+ "company_code": "1000", "currency": "USD", "is_consolidated": false,
44
+ "period_start": "2024-03-01", "period_end": "2024-03-31",
45
+ "nodes": [{ "id": 0, "label": "Revenue", "section": "Revenue", "value": "1200000", "is_subtotal": false, "line_code": "IS-REV" }],
46
+ "links": [{ "source": 0, "target": 1, "value": "480000", "label": "Gross Profit", "is_contra": false }]
47
+ }
48
+ ```
49
+
50
+ `fiscal_period: 0` denotes a full-year rollup. Upload your own `flows.json`, or
51
+ browse the built-in demo (constructed with the same waterfall logic as the
52
+ engine's Rust builder, so the shape is faithful).
53
+
54
+ ## Generate your own
55
+
56
+ ```bash
57
+ datasynth-data generate --config config.yaml --output ./out
58
+ # with financial_reporting: { enabled: true, sankey: true, opex_breakout: true }
59
+ # → ./out/financial_reporting/sankey/flows.json
60
+ ```
app.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VynFi Financial Sankey Explorer.
2
+
3
+ Interactive funds-flow viewer for the `financial_reporting/sankey/flows.json`
4
+ export produced by `datasynth-data` (`financial_reporting.sankey: true`). Pick an
5
+ entity, a period, and a statement and see the **income-statement waterfall**
6
+ (Revenue → Gross Profit → Operating Income → Net Income, with operating-expense
7
+ sub-bands) or the **cash-flow funds-flow** (Operating / Investing / Financing →
8
+ Net change in cash) rendered as a Plotly Sankey.
9
+
10
+ The flow JSON is the engine's native shape — a list of self-describing
11
+ `SankeyFlow` objects:
12
+
13
+ {statement_type, fiscal_year, fiscal_period, company_code, currency,
14
+ is_consolidated, period_start, period_end,
15
+ nodes: [{id, label, section, value, is_subtotal, line_code}],
16
+ links: [{source, target, value, label, is_contra}]}
17
+
18
+ so this Space renders real `datasynth-data` output verbatim. With no dataset
19
+ loaded it falls back to a small built-in demo set (constructed with the same
20
+ waterfall logic as the Rust builder) so the app is self-contained.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from decimal import Decimal
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import gradio as gr
30
+ import plotly.graph_objects as go
31
+
32
+ # ── Section palette (matches the engine's statement sections) ────────────────
33
+ SECTION_COLOR = {
34
+ "Revenue": "#2563eb",
35
+ "Cost of Sales": "#dc2626",
36
+ "Gross Profit": "#16a34a",
37
+ "Operating Expenses": "#ea580c",
38
+ "Operating Income": "#16a34a",
39
+ "Tax": "#9333ea",
40
+ "Net Income": "#15803d",
41
+ "Operating": "#2563eb",
42
+ "Investing": "#9333ea",
43
+ "Financing": "#ea580c",
44
+ "Net Change": "#15803d",
45
+ }
46
+ CONTRA_LINK = "rgba(220, 38, 38, 0.35)" # red-ish: reductions / outflows
47
+ NORMAL_LINK = "rgba(37, 99, 235, 0.30)" # blue-ish: retained / inflows
48
+
49
+
50
+ def _num(v: Any) -> float:
51
+ """Coerce a Sankey value (serde_decimal serialises as a string by default,
52
+ or as a JSON number in CLI mode) to float."""
53
+ if isinstance(v, (int, float)):
54
+ return float(v)
55
+ try:
56
+ return float(Decimal(str(v)))
57
+ except Exception:
58
+ return 0.0
59
+
60
+
61
+ # ── Demo flow builders (mirror the Rust sankey_builder waterfall) ────────────
62
+ def _node(nid, label, section, value, is_subtotal=False, code=None):
63
+ # Magnitudes are absolute (matches the Rust builder; sign lives in is_contra).
64
+ return {
65
+ "id": nid, "label": label, "section": section,
66
+ "value": str(abs(value)), "is_subtotal": is_subtotal, "line_code": code,
67
+ }
68
+
69
+
70
+ def _link(src, tgt, value, label, contra=False):
71
+ return {"source": src, "target": tgt, "value": str(abs(value)), "label": label, "is_contra": contra}
72
+
73
+
74
+ def demo_income_statement(company, fy, fp, period_start, period_end, revenue, cogs, opex_subs):
75
+ """Build an IS waterfall flow exactly as the engine does."""
76
+ gross = revenue - cogs
77
+ opex_total = sum(v for _, v in opex_subs)
78
+ oi = gross - opex_total
79
+ tax = round(oi * 0.21)
80
+ ni = oi - tax
81
+ nodes, links = [], []
82
+ rev_id = len(nodes); nodes.append(_node(rev_id, "Revenue", "Revenue", revenue, code="IS-REV"))
83
+ gp_id = len(nodes); nodes.append(_node(gp_id, "Gross Profit", "Gross Profit", gross, True, "IS-GP"))
84
+ if cogs > 0:
85
+ c_id = len(nodes); nodes.append(_node(c_id, "Cost of Goods Sold", "Cost of Sales", cogs, code="IS-COGS"))
86
+ links.append(_link(rev_id, c_id, cogs, "Cost of Goods Sold", True))
87
+ links.append(_link(rev_id, gp_id, gross, "Gross Profit"))
88
+ oi_id = len(nodes); nodes.append(_node(oi_id, "Operating Income", "Operating Income", oi, True, "IS-OI"))
89
+ for label, amount in opex_subs:
90
+ if amount <= 0:
91
+ continue
92
+ s_id = len(nodes); nodes.append(_node(s_id, label, "Operating Expenses", amount))
93
+ links.append(_link(gp_id, s_id, amount, label, True))
94
+ links.append(_link(gp_id, oi_id, oi, "Operating Income"))
95
+ ni_id = len(nodes); nodes.append(_node(ni_id, "Net Income", "Net Income", ni, True, "IS-NI"))
96
+ if tax > 0:
97
+ t_id = len(nodes); nodes.append(_node(t_id, "Income Tax Expense", "Tax", tax, code="IS-TAX"))
98
+ links.append(_link(oi_id, t_id, tax, "Income Tax Expense", True))
99
+ links.append(_link(oi_id, ni_id, ni, "Net Income"))
100
+ return {
101
+ "statement_type": "income_statement", "fiscal_year": fy, "fiscal_period": fp,
102
+ "company_code": company, "currency": "USD", "is_consolidated": False,
103
+ "period_start": period_start, "period_end": period_end, "nodes": nodes, "links": links,
104
+ }
105
+
106
+
107
+ def demo_cash_flow(company, fy, fp, period_start, period_end, items):
108
+ """Build a CF funds-flow flow exactly as the engine does. `items` =
109
+ list of (label, category, amount) with signed amounts."""
110
+ secs = {"Operating": 0, "Investing": 0, "Financing": 0}
111
+ for _, cat, amt in items:
112
+ secs[cat] += amt
113
+ net = sum(secs.values())
114
+ nodes, links = [], []
115
+ op_id = len(nodes); nodes.append(_node(op_id, "Operating Activities", "Operating", secs["Operating"], True))
116
+ inv_id = len(nodes); nodes.append(_node(inv_id, "Investing Activities", "Investing", secs["Investing"], True))
117
+ fin_id = len(nodes); nodes.append(_node(fin_id, "Financing Activities", "Financing", secs["Financing"], True))
118
+ net_id = len(nodes); nodes.append(_node(net_id, "Net Change in Cash", "Net Change", net, True))
119
+ sec_id = {"Operating": op_id, "Investing": inv_id, "Financing": fin_id}
120
+ for label, cat, amt in items:
121
+ if amt == 0:
122
+ continue
123
+ i_id = len(nodes); nodes.append(_node(i_id, label, cat, amt))
124
+ links.append(_link(i_id, sec_id[cat], amt, label, amt < 0))
125
+ links.append(_link(op_id, net_id, secs["Operating"], "Operating cash flow", secs["Operating"] < 0))
126
+ links.append(_link(inv_id, net_id, secs["Investing"], "Investing cash flow", secs["Investing"] < 0))
127
+ links.append(_link(fin_id, net_id, secs["Financing"], "Financing cash flow", secs["Financing"] < 0))
128
+ return {
129
+ "statement_type": "cash_flow", "fiscal_year": fy, "fiscal_period": fp,
130
+ "company_code": company, "currency": "USD", "is_consolidated": False,
131
+ "period_start": period_start, "period_end": period_end, "nodes": nodes, "links": links,
132
+ }
133
+
134
+
135
+ def _demo_flows():
136
+ flows = []
137
+ opex = [("Salaries & Benefits", 180_000), ("Rent & Facilities", 60_000),
138
+ ("Utilities", 25_000), ("Depreciation & Amortization", 40_000),
139
+ ("Marketing & Advertising", 35_000), ("Other Operating Expenses", 18_000)]
140
+ flows.append(demo_income_statement("1000", 2024, 3, "2024-03-01", "2024-03-31", 1_200_000, 720_000, opex))
141
+ opex6 = [("Salaries & Benefits", 195_000), ("Rent & Facilities", 60_000),
142
+ ("Utilities", 28_000), ("Depreciation & Amortization", 40_000),
143
+ ("Marketing & Advertising", 52_000), ("Other Operating Expenses", 21_000)]
144
+ flows.append(demo_income_statement("1000", 2024, 6, "2024-06-01", "2024-06-30", 1_410_000, 838_000, opex6))
145
+ # Full-year rollup (period 0)
146
+ opex_fy = [(l, v * 12) for l, v in opex]
147
+ flows.append(demo_income_statement("1000", 2024, 0, "2024-01-01", "2024-12-31", 14_900_000, 8_940_000, opex_fy))
148
+ flows.append(demo_cash_flow("1000", 2024, 3, "2024-03-01", "2024-03-31", [
149
+ ("Net Income", "Operating", 110_600), ("Depreciation & Amortization", "Operating", 40_000),
150
+ ("Change in Accounts Receivable", "Operating", -50_000), ("Change in Accounts Payable", "Operating", 20_000),
151
+ ("Change in Inventory", "Operating", -15_000), ("Capital Expenditure", "Investing", -80_000),
152
+ ("Debt Issuance", "Financing", 100_000), ("Dividends Paid", "Financing", -30_000),
153
+ ]))
154
+ # A second entity, so the entity selector is meaningful.
155
+ flows.append(demo_income_statement("2000", 2024, 3, "2024-03-01", "2024-03-31", 640_000, 410_000,
156
+ [("Salaries & Benefits", 95_000), ("Rent & Facilities", 28_000),
157
+ ("Utilities", 12_000), ("Other Operating Expenses", 14_000)]))
158
+ return flows
159
+
160
+
161
+ # ── Flow store ───────────────────────────────────────────────────────────────
162
+ def load_flows(path_or_none) -> list[dict]:
163
+ if path_or_none:
164
+ try:
165
+ data = json.loads(Path(path_or_none).read_text())
166
+ if isinstance(data, list) and data:
167
+ return data
168
+ except Exception:
169
+ pass
170
+ # Bundled file (e.g. shipped alongside the Space), else built-in demo.
171
+ bundled = Path(__file__).with_name("sample_flows.json")
172
+ if bundled.exists():
173
+ try:
174
+ data = json.loads(bundled.read_text())
175
+ if isinstance(data, list) and data:
176
+ return data
177
+ except Exception:
178
+ pass
179
+ return _demo_flows()
180
+
181
+
182
+ def _period_label(flow: dict) -> str:
183
+ fp = flow.get("fiscal_period", 0)
184
+ fy = flow.get("fiscal_year", 0)
185
+ return f"{fy} Full year" if fp == 0 else f"{fy}-{int(fp):02d}"
186
+
187
+
188
+ def _stmt_label(flow: dict) -> str:
189
+ return "Income Statement" if flow.get("statement_type") == "income_statement" else "Cash Flow"
190
+
191
+
192
+ # ── Rendering ─────────────────────────────────────────────────────────────────
193
+ def render(flow: dict) -> go.Figure:
194
+ nodes = flow["nodes"]
195
+ labels = [f"{n['label']} ({_num(n['value']):,.0f})" for n in nodes]
196
+ node_colors = [SECTION_COLOR.get(n.get("section", ""), "#6b7280") for n in nodes]
197
+ links = flow["links"]
198
+ fig = go.Figure(go.Sankey(
199
+ arrangement="snap",
200
+ node=dict(label=labels, color=node_colors, pad=18, thickness=20,
201
+ line=dict(color="rgba(0,0,0,0.25)", width=0.5)),
202
+ link=dict(
203
+ source=[l["source"] for l in links],
204
+ target=[l["target"] for l in links],
205
+ value=[max(_num(l["value"]), 1e-9) for l in links],
206
+ label=[l.get("label", "") for l in links],
207
+ color=[CONTRA_LINK if l.get("is_contra") else NORMAL_LINK for l in links],
208
+ ),
209
+ ))
210
+ cur = flow.get("currency", "USD")
211
+ title = (f"{flow.get('company_code', '')} · {_stmt_label(flow)} · {_period_label(flow)} "
212
+ f"({cur}){' · consolidated' if flow.get('is_consolidated') else ''}")
213
+ fig.update_layout(title_text=title, font_size=12, height=560,
214
+ margin=dict(l=10, r=10, t=50, b=10))
215
+ return fig
216
+
217
+
218
+ def _find(flows, company, period, stmt):
219
+ for f in flows:
220
+ if (f.get("company_code") == company and _period_label(f) == period
221
+ and _stmt_label(f) == stmt):
222
+ return f
223
+ return None
224
+
225
+
226
+ # ── App ───────────────────────────────────────────────────────────────────────
227
+ def build_app():
228
+ state = gr.State(_demo_flows())
229
+
230
+ with gr.Blocks(title="Financial Sankey Explorer", theme=gr.themes.Soft()) as demo:
231
+ gr.Markdown(
232
+ "# 💸 Financial Sankey Explorer\n"
233
+ "Multi-step funds-flow view of synthetic financial statements from "
234
+ "**`datasynth-data`** (`financial_reporting.sankey`). Income-statement "
235
+ "waterfall and cash-flow funds-flow, per entity / period, with operating-"
236
+ "expense sub-bands. Red bands are reductions / outflows; blue are retained "
237
+ "amounts / inflows.\n\n"
238
+ "Upload a `financial_reporting/sankey/flows.json` to explore your own run, "
239
+ "or browse the built-in demo."
240
+ )
241
+ with gr.Row():
242
+ upload = gr.File(label="flows.json (optional)", file_types=[".json"], scale=2)
243
+ entity = gr.Dropdown(label="Entity", scale=1)
244
+ period = gr.Dropdown(label="Period", scale=1)
245
+ stmt = gr.Dropdown(label="Statement", scale=1)
246
+ plot = gr.Plot(label="Funds flow")
247
+
248
+ def _refresh(flows, company=None, period_sel=None, stmt_sel=None):
249
+ companies = sorted({f["company_code"] for f in flows})
250
+ company = company if company in companies else (companies[0] if companies else None)
251
+ periods = sorted({_period_label(f) for f in flows if f["company_code"] == company},
252
+ key=lambda s: (("Full year" in s), s))
253
+ period_sel = period_sel if period_sel in periods else (periods[0] if periods else None)
254
+ stmts = sorted({_stmt_label(f) for f in flows
255
+ if f["company_code"] == company and _period_label(f) == period_sel})
256
+ stmt_sel = stmt_sel if stmt_sel in stmts else (stmts[0] if stmts else None)
257
+ flow = _find(flows, company, period_sel, stmt_sel)
258
+ fig = render(flow) if flow else go.Figure()
259
+ return (gr.update(choices=companies, value=company),
260
+ gr.update(choices=periods, value=period_sel),
261
+ gr.update(choices=stmts, value=stmt_sel), fig)
262
+
263
+ def _on_load(file):
264
+ flows = load_flows(file.name if file else None)
265
+ e, p, s, fig = _refresh(flows)
266
+ return flows, e, p, s, fig
267
+
268
+ def _on_entity(flows, company):
269
+ return _refresh(flows, company)[1:]
270
+
271
+ def _on_period(flows, company, period_sel):
272
+ return _refresh(flows, company, period_sel)[2:]
273
+
274
+ def _on_stmt(flows, company, period_sel, stmt_sel):
275
+ flow = _find(flows, company, period_sel, stmt_sel)
276
+ return render(flow) if flow else go.Figure()
277
+
278
+ upload.change(_on_load, inputs=upload, outputs=[state, entity, period, stmt, plot])
279
+ entity.change(_on_entity, inputs=[state, entity], outputs=[period, stmt, plot])
280
+ period.change(_on_period, inputs=[state, entity, period], outputs=[stmt, plot])
281
+ stmt.change(_on_stmt, inputs=[state, entity, period, stmt], outputs=plot)
282
+
283
+ demo.load(lambda flows: _refresh(flows), inputs=state, outputs=[entity, period, stmt, plot])
284
+ return demo
285
+
286
+
287
+ if __name__ == "__main__":
288
+ build_app().launch()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ plotly>=5.18