ninarg's picture
fix: create gr.State inside Blocks (KeyError on load) + module-level demo
2c65c14 verified
Raw
History Blame Contribute Delete
14.5 kB
"""VynFi Financial Sankey Explorer.
Interactive funds-flow viewer for the `financial_reporting/sankey/flows.json`
export produced by `datasynth-data` (`financial_reporting.sankey: true`). Pick an
entity, a period, and a statement and see the **income-statement waterfall**
(Revenue → Gross Profit → Operating Income → Net Income, with operating-expense
sub-bands) or the **cash-flow funds-flow** (Operating / Investing / Financing →
Net change in cash) rendered as a Plotly Sankey.
The flow JSON is the engine's native shape — a list of self-describing
`SankeyFlow` objects:
{statement_type, fiscal_year, fiscal_period, company_code, currency,
is_consolidated, period_start, period_end,
nodes: [{id, label, section, value, is_subtotal, line_code}],
links: [{source, target, value, label, is_contra}]}
so this Space renders real `datasynth-data` output verbatim. With no dataset
loaded it falls back to a small built-in demo set (constructed with the same
waterfall logic as the Rust builder) so the app is self-contained.
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
import gradio as gr
import plotly.graph_objects as go
# ── Section palette (matches the engine's statement sections) ────────────────
SECTION_COLOR = {
"Revenue": "#2563eb",
"Cost of Sales": "#dc2626",
"Gross Profit": "#16a34a",
"Operating Expenses": "#ea580c",
"Operating Income": "#16a34a",
"Tax": "#9333ea",
"Net Income": "#15803d",
"Operating": "#2563eb",
"Investing": "#9333ea",
"Financing": "#ea580c",
"Net Change": "#15803d",
}
CONTRA_LINK = "rgba(220, 38, 38, 0.35)" # red-ish: reductions / outflows
NORMAL_LINK = "rgba(37, 99, 235, 0.30)" # blue-ish: retained / inflows
def _num(v: Any) -> float:
"""Coerce a Sankey value (serde_decimal serialises as a string by default,
or as a JSON number in CLI mode) to float."""
if isinstance(v, (int, float)):
return float(v)
try:
return float(Decimal(str(v)))
except Exception:
return 0.0
# ── Demo flow builders (mirror the Rust sankey_builder waterfall) ────────────
def _node(nid, label, section, value, is_subtotal=False, code=None):
# Magnitudes are absolute (matches the Rust builder; sign lives in is_contra).
return {
"id": nid, "label": label, "section": section,
"value": str(abs(value)), "is_subtotal": is_subtotal, "line_code": code,
}
def _link(src, tgt, value, label, contra=False):
return {"source": src, "target": tgt, "value": str(abs(value)), "label": label, "is_contra": contra}
def demo_income_statement(company, fy, fp, period_start, period_end, revenue, cogs, opex_subs):
"""Build an IS waterfall flow exactly as the engine does."""
gross = revenue - cogs
opex_total = sum(v for _, v in opex_subs)
oi = gross - opex_total
tax = round(oi * 0.21)
ni = oi - tax
nodes, links = [], []
rev_id = len(nodes); nodes.append(_node(rev_id, "Revenue", "Revenue", revenue, code="IS-REV"))
gp_id = len(nodes); nodes.append(_node(gp_id, "Gross Profit", "Gross Profit", gross, True, "IS-GP"))
if cogs > 0:
c_id = len(nodes); nodes.append(_node(c_id, "Cost of Goods Sold", "Cost of Sales", cogs, code="IS-COGS"))
links.append(_link(rev_id, c_id, cogs, "Cost of Goods Sold", True))
links.append(_link(rev_id, gp_id, gross, "Gross Profit"))
oi_id = len(nodes); nodes.append(_node(oi_id, "Operating Income", "Operating Income", oi, True, "IS-OI"))
for label, amount in opex_subs:
if amount <= 0:
continue
s_id = len(nodes); nodes.append(_node(s_id, label, "Operating Expenses", amount))
links.append(_link(gp_id, s_id, amount, label, True))
links.append(_link(gp_id, oi_id, oi, "Operating Income"))
ni_id = len(nodes); nodes.append(_node(ni_id, "Net Income", "Net Income", ni, True, "IS-NI"))
if tax > 0:
t_id = len(nodes); nodes.append(_node(t_id, "Income Tax Expense", "Tax", tax, code="IS-TAX"))
links.append(_link(oi_id, t_id, tax, "Income Tax Expense", True))
links.append(_link(oi_id, ni_id, ni, "Net Income"))
return {
"statement_type": "income_statement", "fiscal_year": fy, "fiscal_period": fp,
"company_code": company, "currency": "USD", "is_consolidated": False,
"period_start": period_start, "period_end": period_end, "nodes": nodes, "links": links,
}
def demo_cash_flow(company, fy, fp, period_start, period_end, items):
"""Build a CF funds-flow flow exactly as the engine does. `items` =
list of (label, category, amount) with signed amounts."""
secs = {"Operating": 0, "Investing": 0, "Financing": 0}
for _, cat, amt in items:
secs[cat] += amt
net = sum(secs.values())
nodes, links = [], []
op_id = len(nodes); nodes.append(_node(op_id, "Operating Activities", "Operating", secs["Operating"], True))
inv_id = len(nodes); nodes.append(_node(inv_id, "Investing Activities", "Investing", secs["Investing"], True))
fin_id = len(nodes); nodes.append(_node(fin_id, "Financing Activities", "Financing", secs["Financing"], True))
net_id = len(nodes); nodes.append(_node(net_id, "Net Change in Cash", "Net Change", net, True))
sec_id = {"Operating": op_id, "Investing": inv_id, "Financing": fin_id}
for label, cat, amt in items:
if amt == 0:
continue
i_id = len(nodes); nodes.append(_node(i_id, label, cat, amt))
links.append(_link(i_id, sec_id[cat], amt, label, amt < 0))
links.append(_link(op_id, net_id, secs["Operating"], "Operating cash flow", secs["Operating"] < 0))
links.append(_link(inv_id, net_id, secs["Investing"], "Investing cash flow", secs["Investing"] < 0))
links.append(_link(fin_id, net_id, secs["Financing"], "Financing cash flow", secs["Financing"] < 0))
return {
"statement_type": "cash_flow", "fiscal_year": fy, "fiscal_period": fp,
"company_code": company, "currency": "USD", "is_consolidated": False,
"period_start": period_start, "period_end": period_end, "nodes": nodes, "links": links,
}
def _demo_flows():
flows = []
opex = [("Salaries & Benefits", 180_000), ("Rent & Facilities", 60_000),
("Utilities", 25_000), ("Depreciation & Amortization", 40_000),
("Marketing & Advertising", 35_000), ("Other Operating Expenses", 18_000)]
flows.append(demo_income_statement("1000", 2024, 3, "2024-03-01", "2024-03-31", 1_200_000, 720_000, opex))
opex6 = [("Salaries & Benefits", 195_000), ("Rent & Facilities", 60_000),
("Utilities", 28_000), ("Depreciation & Amortization", 40_000),
("Marketing & Advertising", 52_000), ("Other Operating Expenses", 21_000)]
flows.append(demo_income_statement("1000", 2024, 6, "2024-06-01", "2024-06-30", 1_410_000, 838_000, opex6))
# Full-year rollup (period 0)
opex_fy = [(l, v * 12) for l, v in opex]
flows.append(demo_income_statement("1000", 2024, 0, "2024-01-01", "2024-12-31", 14_900_000, 8_940_000, opex_fy))
flows.append(demo_cash_flow("1000", 2024, 3, "2024-03-01", "2024-03-31", [
("Net Income", "Operating", 110_600), ("Depreciation & Amortization", "Operating", 40_000),
("Change in Accounts Receivable", "Operating", -50_000), ("Change in Accounts Payable", "Operating", 20_000),
("Change in Inventory", "Operating", -15_000), ("Capital Expenditure", "Investing", -80_000),
("Debt Issuance", "Financing", 100_000), ("Dividends Paid", "Financing", -30_000),
]))
# A second entity, so the entity selector is meaningful.
flows.append(demo_income_statement("2000", 2024, 3, "2024-03-01", "2024-03-31", 640_000, 410_000,
[("Salaries & Benefits", 95_000), ("Rent & Facilities", 28_000),
("Utilities", 12_000), ("Other Operating Expenses", 14_000)]))
return flows
# ── Flow store ───────────────────────────────────────────────────────────────
def load_flows(path_or_none) -> list[dict]:
if path_or_none:
try:
data = json.loads(Path(path_or_none).read_text())
if isinstance(data, list) and data:
return data
except Exception:
pass
# Bundled file (e.g. shipped alongside the Space), else built-in demo.
bundled = Path(__file__).with_name("sample_flows.json")
if bundled.exists():
try:
data = json.loads(bundled.read_text())
if isinstance(data, list) and data:
return data
except Exception:
pass
return _demo_flows()
def _period_label(flow: dict) -> str:
fp = flow.get("fiscal_period", 0)
fy = flow.get("fiscal_year", 0)
return f"{fy} Full year" if fp == 0 else f"{fy}-{int(fp):02d}"
def _stmt_label(flow: dict) -> str:
return "Income Statement" if flow.get("statement_type") == "income_statement" else "Cash Flow"
# ── Rendering ─────────────────────────────────────────────────────────────────
def render(flow: dict) -> go.Figure:
nodes = flow["nodes"]
labels = [f"{n['label']} ({_num(n['value']):,.0f})" for n in nodes]
node_colors = [SECTION_COLOR.get(n.get("section", ""), "#6b7280") for n in nodes]
links = flow["links"]
fig = go.Figure(go.Sankey(
arrangement="snap",
node=dict(label=labels, color=node_colors, pad=18, thickness=20,
line=dict(color="rgba(0,0,0,0.25)", width=0.5)),
link=dict(
source=[l["source"] for l in links],
target=[l["target"] for l in links],
value=[max(_num(l["value"]), 1e-9) for l in links],
label=[l.get("label", "") for l in links],
color=[CONTRA_LINK if l.get("is_contra") else NORMAL_LINK for l in links],
),
))
cur = flow.get("currency", "USD")
title = (f"{flow.get('company_code', '')} · {_stmt_label(flow)} · {_period_label(flow)} "
f"({cur}){' · consolidated' if flow.get('is_consolidated') else ''}")
fig.update_layout(title_text=title, font_size=12, height=560,
margin=dict(l=10, r=10, t=50, b=10))
return fig
def _find(flows, company, period, stmt):
for f in flows:
if (f.get("company_code") == company and _period_label(f) == period
and _stmt_label(f) == stmt):
return f
return None
# ── App ───────────────────────────────────────────────────────────────────────
def build_app():
with gr.Blocks(title="Financial Sankey Explorer") as demo:
# State MUST be created inside the Blocks context so it is registered
# (a State created outside has an unregistered id → KeyError on every
# callback that reads it, including the on-load).
state = gr.State(_demo_flows())
gr.Markdown(
"# 💸 Financial Sankey Explorer\n"
"Multi-step funds-flow view of synthetic financial statements from "
"**`datasynth-data`** (`financial_reporting.sankey`). Income-statement "
"waterfall and cash-flow funds-flow, per entity / period, with operating-"
"expense sub-bands. Red bands are reductions / outflows; blue are retained "
"amounts / inflows.\n\n"
"Upload a `financial_reporting/sankey/flows.json` to explore your own run, "
"or browse the built-in demo."
)
with gr.Row():
upload = gr.File(label="flows.json (optional)", file_types=[".json"], scale=2)
entity = gr.Dropdown(label="Entity", scale=1)
period = gr.Dropdown(label="Period", scale=1)
stmt = gr.Dropdown(label="Statement", scale=1)
plot = gr.Plot(label="Funds flow")
def _refresh(flows, company=None, period_sel=None, stmt_sel=None):
companies = sorted({f["company_code"] for f in flows})
company = company if company in companies else (companies[0] if companies else None)
periods = sorted({_period_label(f) for f in flows if f["company_code"] == company},
key=lambda s: (("Full year" in s), s))
period_sel = period_sel if period_sel in periods else (periods[0] if periods else None)
stmts = sorted({_stmt_label(f) for f in flows
if f["company_code"] == company and _period_label(f) == period_sel})
stmt_sel = stmt_sel if stmt_sel in stmts else (stmts[0] if stmts else None)
flow = _find(flows, company, period_sel, stmt_sel)
fig = render(flow) if flow else go.Figure()
return (gr.update(choices=companies, value=company),
gr.update(choices=periods, value=period_sel),
gr.update(choices=stmts, value=stmt_sel), fig)
def _on_load(file):
flows = load_flows(file.name if file else None)
e, p, s, fig = _refresh(flows)
return flows, e, p, s, fig
def _on_entity(flows, company):
return _refresh(flows, company)[1:]
def _on_period(flows, company, period_sel):
return _refresh(flows, company, period_sel)[2:]
def _on_stmt(flows, company, period_sel, stmt_sel):
flow = _find(flows, company, period_sel, stmt_sel)
return render(flow) if flow else go.Figure()
upload.change(_on_load, inputs=upload, outputs=[state, entity, period, stmt, plot])
entity.change(_on_entity, inputs=[state, entity], outputs=[period, stmt, plot])
period.change(_on_period, inputs=[state, entity, period], outputs=[stmt, plot])
stmt.change(_on_stmt, inputs=[state, entity, period, stmt], outputs=plot)
demo.load(lambda flows: _refresh(flows), inputs=state, outputs=[entity, period, stmt, plot])
return demo
# Module-level `demo` so the HF Spaces launcher auto-detects it.
demo = build_app()
if __name__ == "__main__":
demo.launch()