diff --git a/cmd/company.go b/cmd/company.go index 4410fbb..1543936 100644 --- a/cmd/company.go +++ b/cmd/company.go @@ -25,6 +25,7 @@ var ( companyFinancialsPeriods int companyFinancialsStatement string companyFinancialsNonConsolidated bool + companyFinancialsSummaryOnly bool ) const ( @@ -172,6 +173,11 @@ var companyFinancialsCmd = &cobra.Command{ if err != nil { return err } + if companyFinancialsSummaryOnly { + for i := range result.Periods { + result.Periods[i].StripStatements() + } + } return outputResult(cmd.OutOrStdout(), result) }, } @@ -286,6 +292,7 @@ func init() { companyFinancialsCmd.Flags().IntVar(&companyFinancialsPeriods, "periods", 3, "Number of fiscal periods (1-10)") companyFinancialsCmd.Flags().StringVar(&companyFinancialsStatement, "statement", "all", "Statement type: bs, pl, cf, all") companyFinancialsCmd.Flags().BoolVar(&companyFinancialsNonConsolidated, "non-consolidated", false, "Prefer non-consolidated statements") + companyFinancialsCmd.Flags().BoolVar(&companyFinancialsSummaryOnly, "summary-only", false, "Output only summary metrics without detailed statements") companyCmd.AddCommand(companySearchCmd) companyCmd.AddCommand(companyFilingsCmd) diff --git a/cmd/doc.go b/cmd/doc.go index bd8c0ab..eadd5fd 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -30,8 +30,9 @@ var ( docTextSection string docTextListSections bool - docFinancialStatement string + docFinancialStatement string docFinancialNonConsolidated bool + docFinancialSummaryOnly bool ) var downloadTypeMap = map[string]int{ @@ -209,6 +210,9 @@ var docFinancialCmd = &cobra.Command{ if err != nil { return err } + if docFinancialSummaryOnly { + result.StripStatements() + } return outputResult(cmd.OutOrStdout(), result) }, } @@ -350,6 +354,7 @@ func init() { docFinancialCmd.Flags().StringVar(&docFinancialStatement, "statement", "all", "Statement type: bs, pl, cf, all") docFinancialCmd.Flags().BoolVar(&docFinancialNonConsolidated, "non-consolidated", false, "Prefer non-consolidated statements") + docFinancialCmd.Flags().BoolVar(&docFinancialSummaryOnly, "summary-only", false, "Output only summary metrics without detailed statements") docCmd.AddCommand(docListCmd) docCmd.AddCommand(docGetCmd) diff --git a/cmd/schema.go b/cmd/schema.go index d7663f3..da8d446 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -43,10 +43,19 @@ var schemaFinancialElementsCmd = &cobra.Command{ }, } +var schemaDerivedMetricsCmd = &cobra.Command{ + Use: "derived-metrics", + Short: "List all derived financial metrics with formulas", + RunE: func(cmd *cobra.Command, args []string) error { + return outputResult(cmd.OutOrStdout(), financial.DerivedMetricDefs()) + }, +} + func init() { schemaCmd.AddCommand(schemaCommandsCmd) schemaCmd.AddCommand(schemaDocTypesCmd) schemaCmd.AddCommand(schemaSectionsCmd) schemaCmd.AddCommand(schemaFinancialElementsCmd) + schemaCmd.AddCommand(schemaDerivedMetricsCmd) rootCmd.AddCommand(schemaCmd) } diff --git a/cmd/schema_test.go b/cmd/schema_test.go index 0faf322..fb5db60 100644 --- a/cmd/schema_test.go +++ b/cmd/schema_test.go @@ -127,6 +127,79 @@ func TestSchemaDocTypes_Contains120(t *testing.T) { } } +func TestSchemaDerivedMetrics_OutputIsJSON(t *testing.T) { + stdout, _, code := executeCommand("schema", "derived-metrics") + if code != 0 { + t.Fatalf("schema derived-metrics exit code = %d, want 0", code) + } + if !json.Valid([]byte(stdout)) { + t.Errorf("output is not valid JSON: %q", stdout[:min(100, len(stdout))]) + } +} + +func TestSchemaDerivedMetrics_AllKeysPresent(t *testing.T) { + stdout, _, _ := executeCommand("schema", "derived-metrics") + var metrics []map[string]any + if err := json.Unmarshal([]byte(stdout), &metrics); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + expectedKeys := []string{"gross_margin", "operating_margin", "net_margin", "roe", "roa", "equity_ratio", "current_ratio", "fcf", "debt_to_equity"} + keySet := make(map[string]bool) + for _, m := range metrics { + if k, ok := m["key"].(string); ok { + keySet[k] = true + } + } + for _, k := range expectedKeys { + if !keySet[k] { + t.Errorf("derived-metrics missing key %q", k) + } + } +} + +func TestSchemaCommands_IncludesSummaryOnlyFlag(t *testing.T) { + stdout, _, _ := executeCommand("schema", "commands") + var cmds []map[string]any + if err := json.Unmarshal([]byte(stdout), &cmds); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + for _, c := range cmds { + name, _ := c["name"].(string) + if name == "company financials" || name == "doc financial" { + flags, _ := c["flags"].([]any) + found := false + for _, f := range flags { + fm, _ := f.(map[string]any) + if fm["name"] == "--summary-only" { + found = true + break + } + } + if !found { + t.Errorf("schema commands %q missing --summary-only flag", name) + } + } + } +} + +func TestSchemaCommands_IncludesDerivedMetrics(t *testing.T) { + stdout, _, _ := executeCommand("schema", "commands") + var cmds []map[string]any + if err := json.Unmarshal([]byte(stdout), &cmds); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + found := false + for _, c := range cmds { + if c["name"] == "schema derived-metrics" { + found = true + break + } + } + if !found { + t.Error("schema commands output missing 'schema derived-metrics'") + } +} + func min(a, b int) int { if a < b { return a diff --git a/internal/financial/classifier.go b/internal/financial/classifier.go index 177e664..5054d20 100644 --- a/internal/financial/classifier.go +++ b/internal/financial/classifier.go @@ -222,7 +222,9 @@ func init() { "jppfs_cor:IncomeTaxes": {StmtPL, "tax", 1130, false, "", "Income taxes - current"}, "jppfs_cor:IncomeTaxesDeferred": {StmtPL, "tax", 1131, false, "", "Income taxes - deferred"}, "jppfs_cor:NetIncome": {StmtPL, "net_income", 1150, true, "net_income", "Net income"}, + "jppfs_cor:ProfitLoss": {StmtPL, "net_income", 1150, true, "net_income", "Profit/loss"}, "jppfs_cor:NetIncomeAttributableToOwnersOfParent": {StmtPL, "net_income", 1151, true, "net_income", "Net income attributable to owners of parent"}, + "jppfs_cor:ProfitLossAttributableToOwnersOfParent": {StmtPL, "net_income", 1151, true, "net_income", "Profit/loss attributable to owners of parent"}, "jppfs_cor:NetIncomeAttributableToNonControllingInterests": {StmtPL, "net_income", 1152, false, "", "Net income attributable to non-controlling interests"}, // ============================================================ @@ -244,6 +246,7 @@ func init() { "jpcrp_cor:ResearchAndDevelopmentExpensesTotal": {StmtPL, "other_pl", 3100, false, "research_and_development", "R&D expenses"}, "jpcrp_cor:DividendPaidPerShareSummaryOfBusinessResults": {StmtPL, "dividends", 3200, false, "dividend_per_share", "Dividend per share"}, "jpcrp_cor:BasicEarningsLossPerShare": {StmtPL, "eps", 3210, false, "eps", "Basic EPS"}, + "jpcrp_cor:BasicEarningsLossPerShareSummaryOfBusinessResults": {StmtPL, "eps", 3211, false, "eps", "Basic EPS (summary)"}, "jpcrp_cor:DilutedEarningsLossPerShare": {StmtPL, "eps", 3220, false, "", "Diluted EPS"}, // ============================================================ @@ -259,6 +262,31 @@ func init() { "jppfs_cor:DeferredTaxAssets": {StmtBS, "current_assets", 141, false, "", "Deferred tax assets (current)"}, "jppfs_cor:DeferredTaxAssetsNCA": {StmtBS, "noncurrent_assets", 251, false, "", "Deferred tax assets (non-current)"}, "jppfs_cor:ResearchAndDevelopmentExpenses": {StmtPL, "other_pl", 3101, false, "research_and_development", "R&D expenses"}, + + // ============================================================ + // SummaryOfBusinessResults fallback elements (jpcrp_cor:) + // These have higher SortOrder than main statement elements so + // populateSummary's "first wins" rule prefers detailed values. + // ============================================================ + + // JP-GAAP SummaryOfBusinessResults + "jpcrp_cor:NetSalesSummaryOfBusinessResults": {StmtPL, "revenue", 1008, true, "revenue", "Net sales (summary)"}, + "jpcrp_cor:OperatingIncomeSummaryOfBusinessResults": {StmtPL, "operating_income", 1068, true, "operating_income", "Operating income (summary)"}, + "jpcrp_cor:OrdinaryIncomeSummaryOfBusinessResults": {StmtPL, "ordinary_income", 1098, true, "ordinary_income", "Ordinary income (summary)"}, + "jpcrp_cor:NetIncomeSummaryOfBusinessResults": {StmtPL, "net_income", 1158, true, "net_income", "Net income (summary)"}, + "jpcrp_cor:ProfitLossAttributableToOwnersOfParentSummaryOfBusinessResults": {StmtPL, "net_income", 1159, true, "net_income", "Net income attributable to parent (summary)"}, + "jpcrp_cor:TotalAssetsSummaryOfBusinessResults": {StmtBS, "total", 308, true, "total_assets", "Total assets (summary)"}, + "jpcrp_cor:NetAssetsSummaryOfBusinessResults": {StmtBS, "equity", 808, true, "net_assets", "Net assets (summary)"}, + + // IFRS SummaryOfBusinessResults + "jpcrp_cor:RevenueIFRSSummaryOfBusinessResults": {StmtPL, "revenue", 1008, true, "revenue", "Revenue IFRS (summary)"}, + "jpcrp_cor:ProfitLossAttributableToOwnersOfParentIFRSSummaryOfBusinessResults": {StmtPL, "net_income", 1138, true, "net_income", "Net income IFRS (summary)"}, + + // US GAAP SummaryOfBusinessResults + "jpcrp_cor:RevenuesUSGAAPSummaryOfBusinessResults": {StmtPL, "revenue", 1008, true, "revenue", "Revenue US GAAP (summary)"}, + "jpcrp_cor:NetIncomeLossAttributableToOwnersOfParentUSGAAPSummaryOfBusinessResults": {StmtPL, "net_income", 1158, true, "net_income", "Net income US GAAP (summary)"}, + "jpcrp_cor:TotalAssetsUSGAAPSummaryOfBusinessResults": {StmtBS, "total", 308, true, "total_assets", "Total assets US GAAP (summary)"}, + "jpcrp_cor:EquityIncludingPortionAttributableToNonControllingInterestUSGAAPSummaryOfBusinessResults": {StmtBS, "equity", 808, true, "net_assets", "Equity US GAAP (summary)"}, } // Company-specific suffix mappings for jpcrp030000-asr_* elements. @@ -270,6 +298,14 @@ func init() { "OperatingProfitLossIFRS": {StmtPL, "operating_income", 1061, true, "operating_income", "Operating profit/loss (company-specific IFRS)"}, "RevenueIFRS": {StmtPL, "revenue", 1005, true, "revenue", "Revenue (company-specific IFRS)"}, "NetSales": {StmtPL, "revenue", 1006, true, "revenue", "Net sales (company-specific)"}, + + // Company-specific revenue variants (e.g., Sony's financial services revenue) + // SortOrder: company-specific (1007) before SummaryOfBusinessResults (1008), + // and KeyFinancialData variants at same level (1007) to match precedence. + "SalesAndFinancialServicesRevenueIFRS": {StmtPL, "revenue", 1007, true, "revenue", "Sales and financial services revenue (IFRS)"}, + "SalesAndFinancialServicesRevenueIFRSKeyFinancialData": {StmtPL, "revenue", 1007, true, "revenue", "Sales and financial services revenue (IFRS, key financial data)"}, + "OperatingProfitLossIFRSKeyFinancialData": {StmtPL, "operating_income", 1067, true, "operating_income", "Operating profit/loss (IFRS, key financial data)"}, + "NetSalesKeyFinancialData": {StmtPL, "revenue", 1007, true, "revenue", "Net sales (key financial data)"}, } } @@ -294,8 +330,7 @@ func Classify(elementID string, pointType string) ElementClassification { // 2. Check company-specific elements (jpcrp030000-asr_*) if strings.HasPrefix(elementID, "jpcrp030000-asr_") { - if colonIdx := strings.Index(elementID, ":"); colonIdx >= 0 { - suffix := elementID[colonIdx+1:] + if suffix := elementLocalName(elementID); suffix != elementID { if def, ok := companySuffixes[suffix]; ok { return ElementClassification{ Statement: def.statement, @@ -315,11 +350,7 @@ func Classify(elementID string, pointType string) ElementClassification { // classifyByKeyword uses keyword matching and pointType to classify unknown elements. // Only positive matches are returned; no fallback to PL or BS. func classifyByKeyword(elementID string, pointType string) ElementClassification { - // Extract the local name (after the colon) for keyword matching - localName := elementID - if colonIdx := strings.Index(elementID, ":"); colonIdx >= 0 { - localName = elementID[colonIdx+1:] - } + localName := elementLocalName(elementID) upper := strings.ToUpper(localName) // CF keywords — must be duration diff --git a/internal/financial/classifier_test.go b/internal/financial/classifier_test.go index 0b69e9c..dbea613 100644 --- a/internal/financial/classifier_test.go +++ b/internal/financial/classifier_test.go @@ -591,3 +591,116 @@ func TestClassify_JPGAAPNoncurrentAssets(t *testing.T) { t.Errorf("Statement = %q, want BS", c.Statement) } } + +// --- SummaryOfBusinessResults fallback element tests --- + +func TestClassify_SummaryOfBusinessResults_JPGAAP(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + {"JP-GAAP revenue summary", "jpcrp_cor:NetSalesSummaryOfBusinessResults", "duration", StmtPL, "revenue"}, + {"JP-GAAP operating income summary", "jpcrp_cor:OperatingIncomeSummaryOfBusinessResults", "duration", StmtPL, "operating_income"}, + {"JP-GAAP ordinary income summary", "jpcrp_cor:OrdinaryIncomeSummaryOfBusinessResults", "duration", StmtPL, "ordinary_income"}, + {"JP-GAAP net income summary", "jpcrp_cor:NetIncomeSummaryOfBusinessResults", "duration", StmtPL, "net_income"}, + {"JP-GAAP total assets summary", "jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "instant", StmtBS, "total_assets"}, + {"JP-GAAP net assets summary", "jpcrp_cor:NetAssetsSummaryOfBusinessResults", "instant", StmtBS, "net_assets"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +func TestClassify_SummaryOfBusinessResults_IFRS(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + {"IFRS revenue summary", "jpcrp_cor:RevenueIFRSSummaryOfBusinessResults", "duration", StmtPL, "revenue"}, + {"IFRS net income summary", "jpcrp_cor:ProfitLossAttributableToOwnersOfParentIFRSSummaryOfBusinessResults", "duration", StmtPL, "net_income"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +func TestClassify_SummaryOfBusinessResults_USGAAP(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + {"US GAAP revenue summary", "jpcrp_cor:RevenuesUSGAAPSummaryOfBusinessResults", "duration", StmtPL, "revenue"}, + {"US GAAP net income summary", "jpcrp_cor:NetIncomeLossAttributableToOwnersOfParentUSGAAPSummaryOfBusinessResults", "duration", StmtPL, "net_income"}, + {"US GAAP total assets summary", "jpcrp_cor:TotalAssetsUSGAAPSummaryOfBusinessResults", "instant", StmtBS, "total_assets"}, + {"US GAAP net assets summary", "jpcrp_cor:EquityIncludingPortionAttributableToNonControllingInterestUSGAAPSummaryOfBusinessResults", "instant", StmtBS, "net_assets"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +func TestClassify_CompanySuffix_RevenueVariants(t *testing.T) { + tests := []struct { + name string + elementID string + wantKey string + }{ + {"SalesAndFinancialServicesRevenueIFRS", "jpcrp030000-asr_E01777-000:SalesAndFinancialServicesRevenueIFRS", "revenue"}, + {"SalesAndFinancialServicesRevenueIFRSKeyFinancialData", "jpcrp030000-asr_E01777-000:SalesAndFinancialServicesRevenueIFRSKeyFinancialData", "revenue"}, + {"OperatingProfitLossIFRSKeyFinancialData", "jpcrp030000-asr_E01777-000:OperatingProfitLossIFRSKeyFinancialData", "operating_income"}, + {"NetSalesKeyFinancialData", "jpcrp030000-asr_E02367-000:NetSalesKeyFinancialData", "revenue"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, "duration") + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +// --- SortOrder precedence: main statement > company-specific > SummaryOfBusinessResults --- + +func TestClassify_SortOrder_MainStatementBeforeFallback(t *testing.T) { + // Main table revenue should have lower SortOrder than SummaryOfBusinessResults revenue + main := Classify("jpigp_cor:RevenueIFRS", "duration") + fallback := Classify("jpcrp_cor:RevenueIFRSSummaryOfBusinessResults", "duration") + + if main.SortOrder >= fallback.SortOrder { + t.Errorf("main SortOrder (%d) should be < fallback SortOrder (%d)", main.SortOrder, fallback.SortOrder) + } +} diff --git a/internal/financial/metrics.go b/internal/financial/metrics.go new file mode 100644 index 0000000..25a6379 --- /dev/null +++ b/internal/financial/metrics.go @@ -0,0 +1,98 @@ +package financial + +// DerivedMetricDef describes a derived financial metric for schema output. +type DerivedMetricDef struct { + Key string `json:"key"` + Formula string `json:"formula"` + Description string `json:"description"` + Requires []string `json:"requires"` +} + +// DerivedMetricDefs returns metadata for all derived metrics. +// This is the single source of truth used by both DeriveMetrics() and schema output. +func DerivedMetricDefs() []DerivedMetricDef { + return []DerivedMetricDef{ + {Key: "gross_margin", Formula: "gross_profit / revenue", Description: "Gross profit margin", Requires: []string{"gross_profit", "revenue"}}, + {Key: "operating_margin", Formula: "operating_income / revenue", Description: "Operating profit margin", Requires: []string{"operating_income", "revenue"}}, + {Key: "net_margin", Formula: "net_income / revenue", Description: "Net profit margin", Requires: []string{"net_income", "revenue"}}, + {Key: "roe", Formula: "net_income / equity (or net_assets)", Description: "Return on equity (ending-balance approximation)", Requires: []string{"net_income", "equity or net_assets"}}, + {Key: "roa", Formula: "net_income / total_assets", Description: "Return on assets (ending-balance approximation)", Requires: []string{"net_income", "total_assets"}}, + {Key: "equity_ratio", Formula: "equity (or net_assets) / total_assets", Description: "Equity ratio", Requires: []string{"equity or net_assets", "total_assets"}}, + {Key: "current_ratio", Formula: "current_assets / current_liabilities", Description: "Current ratio", Requires: []string{"current_assets", "current_liabilities"}}, + {Key: "fcf", Formula: "operating_cf + investing_cf", Description: "Free cash flow (simplified)", Requires: []string{"operating_cf", "investing_cf"}}, + {Key: "debt_to_equity", Formula: "interest_bearing_debt / equity (or net_assets)", Description: "Debt-to-equity ratio", Requires: []string{"interest_bearing_debt", "equity or net_assets"}}, + } +} + +// DeriveMetrics calculates derived financial metrics and adds them to the summary. +// Metrics are skipped silently when prerequisite values are missing or denominators are zero. +// Existing keys are never overwritten. +func DeriveMetrics(summary Summary) { + // Helper to get a value or nil + get := func(key string) *float64 { return summary[key] } + + // Helper to set a value only if the key doesn't already exist + set := func(key string, val float64) { + if _, exists := summary[key]; !exists { + v := val + summary[key] = &v + } + } + + // Helper for safe division (returns nil if denominator is zero or nil) + div := func(num, denom *float64) *float64 { + if num == nil || denom == nil || *denom == 0 { + return nil + } + v := *num / *denom + return &v + } + + // Equity with fallback to net_assets + eq := equityValue(summary) + + // Margin metrics + if v := div(get("gross_profit"), get("revenue")); v != nil { + set("gross_margin", *v) + } + if v := div(get("operating_income"), get("revenue")); v != nil { + set("operating_margin", *v) + } + if v := div(get("net_income"), get("revenue")); v != nil { + set("net_margin", *v) + } + + // Return metrics + if v := div(get("net_income"), eq); v != nil { + set("roe", *v) + } + if v := div(get("net_income"), get("total_assets")); v != nil { + set("roa", *v) + } + + // Balance sheet ratios + if v := div(eq, get("total_assets")); v != nil { + set("equity_ratio", *v) + } + if v := div(get("current_assets"), get("current_liabilities")); v != nil { + set("current_ratio", *v) + } + if v := div(get("interest_bearing_debt"), eq); v != nil { + set("debt_to_equity", *v) + } + + // FCF (additive, not a ratio) + ocf := get("operating_cf") + icf := get("investing_cf") + if ocf != nil && icf != nil { + set("fcf", *ocf+*icf) + } +} + +// equityValue returns equity if present, otherwise falls back to net_assets. +func equityValue(summary Summary) *float64 { + if v := summary["equity"]; v != nil { + return v + } + return summary["net_assets"] +} diff --git a/internal/financial/metrics_test.go b/internal/financial/metrics_test.go new file mode 100644 index 0000000..643b1a8 --- /dev/null +++ b/internal/financial/metrics_test.go @@ -0,0 +1,153 @@ +package financial + +import "testing" + +func assertMetricNil(t *testing.T, s Summary, key string) { + t.Helper() + if v := s[key]; v != nil { + t.Errorf("Summary[%q] = %v, want nil", key, *v) + } +} + +func TestDeriveMetrics_AllMetrics(t *testing.T) { + s := Summary{ + "revenue": ptrFloat(10000), + "gross_profit": ptrFloat(5000), + "operating_income": ptrFloat(2000), + "net_income": ptrFloat(1000), + "equity": ptrFloat(8000), + "total_assets": ptrFloat(20000), + "current_assets": ptrFloat(6000), + "current_liabilities": ptrFloat(3000), + "operating_cf": ptrFloat(3000), + "investing_cf": ptrFloat(-1000), + "interest_bearing_debt": ptrFloat(4000), + } + + DeriveMetrics(s) + + assertSummaryValue(t, s, "gross_margin", 0.5) + assertSummaryValue(t, s, "operating_margin", 0.2) + assertSummaryValue(t, s, "net_margin", 0.1) + assertSummaryValue(t, s, "roe", 0.125) // 1000/8000 + assertSummaryValue(t, s, "roa", 0.05) // 1000/20000 + assertSummaryValue(t, s, "equity_ratio", 0.4) // 8000/20000 + assertSummaryValue(t, s, "current_ratio", 2.0) // 6000/3000 + assertSummaryValue(t, s, "fcf", 2000) // 3000 + (-1000) + assertSummaryValue(t, s, "debt_to_equity", 0.5) // 4000/8000 +} + +func TestDeriveMetrics_MissingPrerequisites(t *testing.T) { + s := Summary{ + "revenue": ptrFloat(10000), + // no other keys + } + + DeriveMetrics(s) + + // Only gross_margin should be skipped (no gross_profit) + assertMetricNil(t, s, "gross_margin") + assertMetricNil(t, s, "roe") + assertMetricNil(t, s, "roa") + assertMetricNil(t, s, "fcf") +} + +func TestDeriveMetrics_ZeroDenominator(t *testing.T) { + s := Summary{ + "revenue": ptrFloat(0), + "gross_profit": ptrFloat(0), + "net_income": ptrFloat(1000), + "equity": ptrFloat(0), + "total_assets": ptrFloat(0), + "current_liabilities": ptrFloat(0), + } + + DeriveMetrics(s) // should not panic + + assertMetricNil(t, s, "gross_margin") + assertMetricNil(t, s, "roe") + assertMetricNil(t, s, "roa") + assertMetricNil(t, s, "current_ratio") +} + +func TestDeriveMetrics_NegativeValues(t *testing.T) { + s := Summary{ + "revenue": ptrFloat(10000), + "net_income": ptrFloat(-500), + "equity": ptrFloat(8000), + } + + DeriveMetrics(s) + + assertSummaryValue(t, s, "net_margin", -0.05) // -500/10000 + assertSummaryValue(t, s, "roe", -0.0625) // -500/8000 +} + +func TestDeriveMetrics_DoesNotOverwrite(t *testing.T) { + s := Summary{ + "revenue": ptrFloat(10000), + "operating_income": ptrFloat(2000), + "operating_margin": ptrFloat(0.999), // pre-existing + } + + DeriveMetrics(s) + + assertSummaryValue(t, s, "operating_margin", 0.999) // should NOT be overwritten +} + +func TestDeriveMetrics_EmptySummary(t *testing.T) { + s := Summary{} + DeriveMetrics(s) // should not panic + if len(s) != 0 { + t.Errorf("empty summary should remain empty, got %d keys", len(s)) + } +} + +func TestDeriveMetrics_EquityFallbackToNetAssets(t *testing.T) { + s := Summary{ + "net_income": ptrFloat(1000), + "net_assets": ptrFloat(5000), + "total_assets": ptrFloat(20000), + // no "equity" key + } + + DeriveMetrics(s) + + assertSummaryValue(t, s, "roe", 0.2) // 1000/5000 (net_assets fallback) + assertSummaryValue(t, s, "equity_ratio", 0.25) // 5000/20000 +} + +func TestDeriveMetrics_EquityPreferredOverNetAssets(t *testing.T) { + s := Summary{ + "net_income": ptrFloat(1000), + "equity": ptrFloat(8000), + "net_assets": ptrFloat(10000), // should be ignored + "total_assets": ptrFloat(20000), + } + + DeriveMetrics(s) + + assertSummaryValue(t, s, "roe", 0.125) // 1000/8000 (equity preferred) + assertSummaryValue(t, s, "equity_ratio", 0.4) // 8000/20000 +} + +func TestDerivedMetrics_MetadataCompleteness(t *testing.T) { + defs := DerivedMetricDefs() + if len(defs) == 0 { + t.Fatal("DerivedMetricDefs() returned empty slice") + } + for _, d := range defs { + if d.Key == "" { + t.Error("DerivedMetricDef has empty Key") + } + if d.Formula == "" { + t.Errorf("DerivedMetricDef %q has empty Formula", d.Key) + } + if d.Description == "" { + t.Errorf("DerivedMetricDef %q has empty Description", d.Key) + } + if d.Requires == nil { + t.Errorf("DerivedMetricDef %q has nil Requires", d.Key) + } + } +} diff --git a/internal/financial/parser.go b/internal/financial/parser.go index 3627929..504c54c 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -296,7 +296,6 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes acctStd := detectAccountingStandard(selectedRows) // Build statements - summary := make(Summary) var statements []FinancialStatement seen := make(map[dedupeKey]bool) @@ -307,11 +306,9 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes } } - // Build summary from current period items - buildSummary(summary, statements) - + // Build summary from current period items and derive metrics return &ParseResult{ - Summary: summary, + Summary: BuildAndDeriveSummary(statements), Statements: statements, AccountingStd: acctStd, Consolidated: hasConsolidatedStmt, @@ -363,6 +360,7 @@ func selectConsolidation(sr *consolidationGroup, st StatementType, opts ParseOpt } // Neither consolidated nor non-consolidated, but neutral rows exist if len(neutralOther) > 0 { + *warnings = append(*warnings, fmt.Sprintf("statement %s: non-consolidated data not available; summary values populated from SummaryOfBusinessResults (neutral) rows", st)) return nonConsRows, false } return nil, false @@ -398,6 +396,39 @@ func detectAccountingStandard(rows []parsedRow) string { ifrsCount++ case "jppfs_cor": jpgaapCount++ + case "jpcrp_cor": + // Only count SummaryOfBusinessResults/KeyFinancialData elements + // with core financial summaryKeys as accounting standard indicators. + // Cross-standard items (dividend, shares, eps) do not imply a standard. + localName := elementLocalName(r.elementID) + if !strings.HasSuffix(localName, "SummaryOfBusinessResults") && !strings.HasSuffix(localName, "KeyFinancialData") { + continue + } + // Only count if the element maps to a core financial metric + key := r.classification.SummaryKey + if key != "revenue" && key != "operating_income" && key != "ordinary_income" && + key != "net_income" && key != "total_assets" && key != "net_assets" { + continue + } + if strings.Contains(localName, "IFRS") { + ifrsCount++ + } else if strings.Contains(localName, "USGAAP") { + // US GAAP detection: currently not needed as a separate standard + } else { + // Non-IFRS/USGAAP jpcrp_cor rows with core financial summaryKeys + // imply JP-GAAP (e.g. NetSalesSummaryOfBusinessResults). + jpgaapCount++ + } + default: + // Company-specific elements (jpcrp030000-asr_*) + if strings.HasPrefix(prefix, "jpcrp030000-asr_") && r.classification.SummaryKey != "" { + localName := elementLocalName(r.elementID) + if strings.Contains(localName, "IFRS") { + ifrsCount++ + } + // Non-IFRS company-specific rows (e.g. NetSalesKeyFinancialData) + // are standard-neutral and should not influence detection. + } } } @@ -421,6 +452,14 @@ func elementPrefix(elementID string) string { return "" } +// elementLocalName extracts the local name from an element ID (after the colon). +func elementLocalName(elementID string) string { + if idx := strings.Index(elementID, ":"); idx >= 0 { + return elementID[idx+1:] + } + return elementID +} + // buildStatement constructs a FinancialStatement from rows. func buildStatement(stmtType StatementType, rows []parsedRow, consolidated bool, acctStd string, seen map[dedupeKey]bool) *FinancialStatement { if len(rows) == 0 { @@ -534,38 +573,3 @@ func periodOrder(period string) int { } } -// buildSummary populates the Summary map from current period items. -func buildSummary(summary Summary, statements []FinancialStatement) { - additiveKeys := map[string]bool{ - "interest_bearing_debt": true, - } - - for _, stmt := range statements { - for _, pd := range stmt.Periods { - if pd.Period != "current" && pd.Period != "filing_date" { - continue - } - for _, item := range pd.Items { - if item.SummaryKey == "" || item.Value == nil { - continue - } - - if additiveKeys[item.SummaryKey] { - existing := summary[item.SummaryKey] - if existing == nil { - v := *item.Value - summary[item.SummaryKey] = &v - } else { - v := *existing + *item.Value - summary[item.SummaryKey] = &v - } - } else { - if _, exists := summary[item.SummaryKey]; !exists { - v := *item.Value - summary[item.SummaryKey] = &v - } - } - } - } - } -} diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go index a8a0082..72a8368 100644 --- a/internal/financial/parser_test.go +++ b/internal/financial/parser_test.go @@ -29,6 +29,8 @@ func makeRow(elementID, label, contextID, relYear, consolidated, pointType, unit return []string{elementID, label, contextID, relYear, consolidated, pointType, unitID, unit, value} } +func ptrFloat(v float64) *float64 { return &v } + // findSummaryKey returns the summary value for a key, or nil if not present. func findSummaryKey(s Summary, key string) *float64 { if s == nil { @@ -1028,3 +1030,127 @@ func assertSummaryValue(t *testing.T, s Summary, key string, want float64) { t.Errorf("Summary[%q] = %v, want %v", key, *v, want) } } + +// --- SummaryOfBusinessResults precedence tests --- + +func TestParse_SummaryPrecedence_DetailedWinsOverFallback(t *testing.T) { + // When both detailed (jppfs_cor) and SummaryOfBusinessResults (jpcrp_cor) rows exist, + // the detailed value should win because it has lower SortOrder. + file := makeCSVFile( + "jpcrp030000-asr-001_E02367-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Detailed revenue (SortOrder 1000, should win) + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000000000"), + // SummaryOfBusinessResults revenue (SortOrder 1008, should be ignored) + makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "CurrentYearDuration", "当期", "", "期間", "JPY", "円", "9999999999999"), + // Operating income (only from summary — should be used as fallback) + makeRow("jpcrp_cor:OperatingIncomeSummaryOfBusinessResults", "営業利益、経営指標等", "CurrentYearDuration", "当期", "", "期間", "JPY", "円", "200000000000"), + }, + ) + + result, err := Parse(&extract.CSVDataResult{Files: []extract.CSVFile{file}}, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Detailed value wins for revenue + assertSummaryValue(t, result.Summary, "revenue", 1000000000000) + // Fallback used for operating income (no detailed row) + assertSummaryValue(t, result.Summary, "operating_income", 200000000000) +} + +func TestParse_SummaryFallback_UsedWhenDetailedMissing(t *testing.T) { + // When only SummaryOfBusinessResults rows exist, they should populate summary. + file := makeCSVFile( + "jpcrp030000-asr-001_E02367-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "総資産額、経営指標等", "CurrentYearInstant", "当期", "", "時点", "JPY", "円", "3000000000000"), + makeRow("jpcrp_cor:NetAssetsSummaryOfBusinessResults", "純資産額、経営指標等", "CurrentYearInstant", "当期", "", "時点", "JPY", "円", "2000000000000"), + makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "CurrentYearDuration", "当期", "", "期間", "JPY", "円", "1500000000000"), + makeRow("jpcrp_cor:NetIncomeSummaryOfBusinessResults", "当期純利益、経営指標等", "CurrentYearDuration", "当期", "", "期間", "JPY", "円", "400000000000"), + }, + ) + + result, err := Parse(&extract.CSVDataResult{Files: []extract.CSVFile{file}}, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "total_assets", 3000000000000) + assertSummaryValue(t, result.Summary, "net_assets", 2000000000000) + assertSummaryValue(t, result.Summary, "revenue", 1500000000000) + assertSummaryValue(t, result.Summary, "net_income", 400000000000) +} + +func TestParse_NonConsolidated_NeutralFallback_EmitsWarning(t *testing.T) { + // When explicit non-consolidated is requested but only neutral rows exist, + // a warning should be emitted. + file := makeCSVFile( + "jpcrp030000-asr-001_E02367-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Only neutral (jpcrp_cor) rows, no actual non-consolidated rows + makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "CurrentYearDuration", "当期", "", "期間", "JPY", "円", "1000000000000"), + makeRow("jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "総資産額、経営指標等", "CurrentYearInstant", "当期", "", "時点", "JPY", "円", "3000000000000"), + }, + ) + + nonCons := false + result, err := Parse(&extract.CSVDataResult{Files: []extract.CSVFile{file}}, ParseOpts{Consolidated: &nonCons}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should still produce summary values + assertSummaryValue(t, result.Summary, "revenue", 1000000000000) + + // Should have a warning about neutral fallback + hasWarning := false + for _, w := range result.Warnings { + if strings.Contains(w, "neutral") || strings.Contains(w, "SummaryOfBusinessResults") { + hasWarning = true + break + } + } + if !hasWarning { + t.Errorf("expected warning about neutral fallback in non-consolidated mode, got warnings: %v", result.Warnings) + } +} + +// --- detectAccountingStandard with SummaryOfBusinessResults rows --- + +func TestDetectAccountingStandard_IFRSSummaryRowsOnly(t *testing.T) { + rows := []parsedRow{ + {elementID: "jpcrp_cor:RevenueIFRSSummaryOfBusinessResults", classification: ElementClassification{Statement: StmtPL, SummaryKey: "revenue"}}, + {elementID: "jpcrp_cor:ProfitLossAttributableToOwnersOfParentIFRSSummaryOfBusinessResults", classification: ElementClassification{Statement: StmtPL, SummaryKey: "net_income"}}, + } + std := detectAccountingStandard(rows) + if std != "ifrs" { + t.Errorf("detectAccountingStandard = %q, want %q", std, "ifrs") + } +} + +func TestDetectAccountingStandard_JPGAAPSummaryRowsOnly(t *testing.T) { + rows := []parsedRow{ + {elementID: "jpcrp_cor:NetSalesSummaryOfBusinessResults", classification: ElementClassification{Statement: StmtPL, SummaryKey: "revenue"}}, + {elementID: "jpcrp_cor:TotalAssetsSummaryOfBusinessResults", classification: ElementClassification{Statement: StmtBS, SummaryKey: "total_assets"}}, + } + std := detectAccountingStandard(rows) + if std != "jpgaap" { + t.Errorf("detectAccountingStandard = %q, want %q", std, "jpgaap") + } +} + +func TestDetectAccountingStandard_NeutralOnlyRows_StaysUnknown(t *testing.T) { + // Neutral rows like dividend/shares should NOT determine accounting standard + rows := []parsedRow{ + {elementID: "jpcrp_cor:DividendPaidPerShareSummaryOfBusinessResults", classification: ElementClassification{Statement: StmtPL, SummaryKey: "dividend_per_share"}}, + {elementID: "jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", classification: ElementClassification{Statement: StmtBS, SummaryKey: "shares_outstanding"}}, + } + std := detectAccountingStandard(rows) + if std != "unknown" { + t.Errorf("detectAccountingStandard = %q, want %q", std, "unknown") + } +} diff --git a/internal/financial/summary.go b/internal/financial/summary.go new file mode 100644 index 0000000..6e734c4 --- /dev/null +++ b/internal/financial/summary.go @@ -0,0 +1,48 @@ +package financial + +// BuildAndDeriveSummary builds the summary from statements and calculates derived metrics. +// This is the single entry point for summary construction, used by both the parser +// and the service layer's statement-filtering path. +func BuildAndDeriveSummary(statements []FinancialStatement) Summary { + summary := make(Summary) + populateSummary(summary, statements) + DeriveMetrics(summary) + return summary +} + +// populateSummary extracts key financial figures from the current period items +// of the given statements and stores them in the summary map. +func populateSummary(summary Summary, statements []FinancialStatement) { + additiveKeys := map[string]bool{ + "interest_bearing_debt": true, + } + + for _, stmt := range statements { + for _, pd := range stmt.Periods { + if pd.Period != "current" && pd.Period != "filing_date" { + continue + } + for _, item := range pd.Items { + if item.SummaryKey == "" || item.Value == nil { + continue + } + + if additiveKeys[item.SummaryKey] { + existing := summary[item.SummaryKey] + if existing == nil { + v := *item.Value + summary[item.SummaryKey] = &v + } else { + v := *existing + *item.Value + summary[item.SummaryKey] = &v + } + } else { + if _, exists := summary[item.SummaryKey]; !exists { + v := *item.Value + summary[item.SummaryKey] = &v + } + } + } + } + } +} diff --git a/internal/financial/summary_test.go b/internal/financial/summary_test.go new file mode 100644 index 0000000..06ebe4d --- /dev/null +++ b/internal/financial/summary_test.go @@ -0,0 +1,74 @@ +package financial + +import "testing" + +func TestBuildAndDeriveSummary_IntegrationWithStatements(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: true, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "current", Items: []LineItem{ + {ElementID: "jppfs_cor:NetSales", SummaryKey: "revenue", Value: ptrFloat(10000)}, + {ElementID: "jppfs_cor:OperatingIncome", SummaryKey: "operating_income", Value: ptrFloat(2000)}, + {ElementID: "jppfs_cor:NetIncome", SummaryKey: "net_income", Value: ptrFloat(1000)}, + }}, + }, + }, + { + Type: "bs", Consolidated: true, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "current", Items: []LineItem{ + {ElementID: "jppfs_cor:TotalAssets", SummaryKey: "total_assets", Value: ptrFloat(50000)}, + {ElementID: "jppfs_cor:ShareholdersEquity", SummaryKey: "equity", Value: ptrFloat(20000)}, + }}, + }, + }, + } + + s := BuildAndDeriveSummary(stmts) + + // Extracted values + assertSummaryValue(t, s, "revenue", 10000) + assertSummaryValue(t, s, "operating_income", 2000) + assertSummaryValue(t, s, "net_income", 1000) + assertSummaryValue(t, s, "total_assets", 50000) + assertSummaryValue(t, s, "equity", 20000) + + // Derived values + assertSummaryValue(t, s, "operating_margin", 0.2) + assertSummaryValue(t, s, "roe", 0.05) // 1000/20000 + assertSummaryValue(t, s, "roa", 0.02) // 1000/50000 + assertSummaryValue(t, s, "equity_ratio", 0.4) // 20000/50000 +} + +func TestBuildAndDeriveSummary_FilteredStatementsOmitIrrelevantMetrics(t *testing.T) { + // PL-only input: BS-derived metrics should not appear + plOnly := []FinancialStatement{ + { + Type: "pl", Consolidated: true, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "current", Items: []LineItem{ + {ElementID: "jppfs_cor:NetSales", SummaryKey: "revenue", Value: ptrFloat(10000)}, + {ElementID: "jppfs_cor:NetIncome", SummaryKey: "net_income", Value: ptrFloat(1000)}, + }}, + }, + }, + } + + s := BuildAndDeriveSummary(plOnly) + + // PL-derived metrics should be present + assertSummaryValue(t, s, "net_margin", 0.1) + + // BS-derived metrics should NOT be present (no BS data) + if s["equity_ratio"] != nil { + t.Errorf("equity_ratio should be nil without BS data, got %v", *s["equity_ratio"]) + } + if s["roe"] != nil { + t.Errorf("roe should be nil without BS data, got %v", *s["roe"]) + } + if s["current_ratio"] != nil { + t.Errorf("current_ratio should be nil without BS data, got %v", *s["current_ratio"]) + } +} + diff --git a/internal/financial/types.go b/internal/financial/types.go index 5fb6e65..5316c9d 100644 --- a/internal/financial/types.go +++ b/internal/financial/types.go @@ -1,7 +1,9 @@ package financial -// Summary holds key financial figures extracted from the current period. -// Keys are standardized English names (e.g., "revenue", "total_assets"). +// Summary holds key financial figures for the current period. +// Keys include both extracted values (e.g., "revenue", "total_assets") from financial +// statements and derived metrics (e.g., "roe", "operating_margin") calculated from them. +// Use schema derived-metrics to discover which keys are derived and their formulas. // nil values indicate the item was not found or not applicable. type Summary map[string]*float64 @@ -57,6 +59,12 @@ type CompanyInfo struct { Name string `json:"name,omitempty"` } +// StripStatements removes detailed statement data, keeping only summary and metadata. +// After stripping, JSON output will contain "statements": null. +func (d *FinancialData) StripStatements() { + d.Statements = nil +} + // FinancialData is the final output of the service layer with document metadata. type FinancialData struct { DocID string `json:"doc_id"` diff --git a/internal/financial/types_test.go b/internal/financial/types_test.go index 2219f9c..2985e7c 100644 --- a/internal/financial/types_test.go +++ b/internal/financial/types_test.go @@ -2,6 +2,7 @@ package financial import ( "encoding/json" + "strings" "testing" ) @@ -71,3 +72,50 @@ func TestFinancialData_JSONOutput(t *testing.T) { t.Error("output is not valid JSON") } } + +func TestFinancialData_StripStatements(t *testing.T) { + val := 1000.0 + fd := FinancialData{ + DocID: "S100TEST", + FiscalYear: "2025-03-31", + AccountingStd: "jpgaap", + Consolidated: true, + Summary: Summary{ + "revenue": &val, + }, + Statements: []FinancialStatement{ + {Type: "pl", Consolidated: true}, + }, + } + + fd.StripStatements() + + // Statements should be nil + if fd.Statements != nil { + t.Errorf("Statements should be nil after StripStatements, got %v", fd.Statements) + } + + // Metadata should be preserved + if fd.DocID != "S100TEST" { + t.Errorf("DocID = %q, want %q", fd.DocID, "S100TEST") + } + if fd.FiscalYear != "2025-03-31" { + t.Errorf("FiscalYear = %q, want %q", fd.FiscalYear, "2025-03-31") + } + if fd.AccountingStd != "jpgaap" { + t.Errorf("AccountingStd = %q, want %q", fd.AccountingStd, "jpgaap") + } + if fd.Summary["revenue"] == nil || *fd.Summary["revenue"] != val { + t.Error("Summary should be preserved after StripStatements") + } + + // JSON should contain "statements":null + data, err := json.Marshal(fd) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + jsonStr := string(data) + if !strings.Contains(jsonStr, `"statements":null`) { + t.Errorf("JSON should contain \"statements\":null, got: %s", jsonStr) + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index b0391fc..cd60bf7 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -134,6 +134,7 @@ func ListCommands() []CommandInfo { Flags: []FlagInfo{ {Name: "--statement", Type: "string", Default: "all", Description: "Statement type: bs, pl, cf, all"}, {Name: "--non-consolidated", Type: "bool", Description: "Prefer non-consolidated statements"}, + {Name: "--summary-only", Type: "bool", Description: "Output only summary metrics without detailed statements"}, }, Examples: []string{ "edinet doc financial S100ABCD", @@ -170,6 +171,7 @@ func ListCommands() []CommandInfo { {Name: "--periods", Type: "int", Default: "3", Description: "Number of fiscal periods (1-10)"}, {Name: "--statement", Type: "string", Default: "all", Description: "Statement type: bs, pl, cf, all"}, {Name: "--non-consolidated", Type: "bool", Description: "Prefer non-consolidated statements"}, + {Name: "--summary-only", Type: "bool", Description: "Output only summary metrics without detailed statements"}, }, Examples: []string{ "edinet company financials E02144", @@ -186,6 +188,10 @@ func ListCommands() []CommandInfo { Name: "schema commands", Description: "List all CLI commands with flags", }, + { + Name: "schema derived-metrics", + Description: "List all derived financial metrics with formulas", + }, { Name: "schema doc-types", Description: "List all document type codes", diff --git a/internal/service/financial.go b/internal/service/financial.go index 982baf8..16e9778 100644 --- a/internal/service/financial.go +++ b/internal/service/financial.go @@ -214,39 +214,6 @@ func (s *FinancialService) GetCompanyFinancials(ctx context.Context, companySvc return result, nil } -// rebuildSummary builds a summary from the given statements' current period items. -func rebuildSummary(stmts []financial.FinancialStatement) financial.Summary { - summary := make(financial.Summary) - additiveKeys := map[string]bool{"interest_bearing_debt": true} - - for _, stmt := range stmts { - for _, pd := range stmt.Periods { - if pd.Period != "current" && pd.Period != "filing_date" { - continue - } - for _, item := range pd.Items { - if item.SummaryKey == "" || item.Value == nil { - continue - } - if additiveKeys[item.SummaryKey] { - existing := summary[item.SummaryKey] - if existing == nil { - v := *item.Value - summary[item.SummaryKey] = &v - } else { - v := *existing + *item.Value - summary[item.SummaryKey] = &v - } - } else if _, exists := summary[item.SummaryKey]; !exists { - v := *item.Value - summary[item.SummaryKey] = &v - } - } - } - } - return summary -} - // parseAndBuild parses CSV data and builds the FinancialData output. func (s *FinancialService) parseAndBuild(csvResult *extract.CSVDataResult, docID string, opts StatementOpts) (*financial.FinancialData, error) { parseOpts := financial.ParseOpts{ @@ -303,7 +270,7 @@ func (s *FinancialService) parseAndBuild(csvResult *extract.CSVDataResult, docID data.Consolidated = hasCons // Rebuild summary from only the filtered statements - data.Summary = rebuildSummary(data.Statements) + data.Summary = financial.BuildAndDeriveSummary(data.Statements) } // Empty result check