Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/company.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var (
companyFinancialsPeriods int
companyFinancialsStatement string
companyFinancialsNonConsolidated bool
companyFinancialsSummaryOnly bool
)

const (
Expand Down Expand Up @@ -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)
},
}
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion cmd/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ var (
docTextSection string
docTextListSections bool

docFinancialStatement string
docFinancialStatement string
docFinancialNonConsolidated bool
docFinancialSummaryOnly bool
)

var downloadTypeMap = map[string]int{
Expand Down Expand Up @@ -209,6 +210,9 @@ var docFinancialCmd = &cobra.Command{
if err != nil {
return err
}
if docFinancialSummaryOnly {
result.StripStatements()
}
return outputResult(cmd.OutOrStdout(), result)
},
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
73 changes: 73 additions & 0 deletions cmd/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 38 additions & 7 deletions internal/financial/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},

// ============================================================
Expand All @@ -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"},

// ============================================================
Expand All @@ -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.
Expand All @@ -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)"},
}
}

Expand All @@ -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,
Expand All @@ -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
Expand Down
113 changes: 113 additions & 0 deletions internal/financial/classifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading