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
11 changes: 7 additions & 4 deletions cmd/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ var (
docListDocType string
docListSecCode string
docListEdinetCode string
docListFilerName string
docListRateLimit int
docListFilerName string
docListDocDescription string
docListRateLimit int

docGetType string
docGetOut string
Expand Down Expand Up @@ -78,8 +79,9 @@ var docListCmd = &cobra.Command{
DocType: docListDocType,
SecCode: docListSecCode,
EdinetCode: docListEdinetCode,
FilerName: docListFilerName,
RateLimit: time.Duration(docListRateLimit) * time.Millisecond,
FilerName: docListFilerName,
DocDescription: docListDocDescription,
RateLimit: time.Duration(docListRateLimit) * time.Millisecond,
})
if err != nil {
return err
Expand Down Expand Up @@ -344,6 +346,7 @@ func init() {
docListCmd.Flags().StringVar(&docListSecCode, "sec-code", "", "Filter by securities code")
docListCmd.Flags().StringVar(&docListEdinetCode, "edinet-code", "", "Filter by EDINET code")
docListCmd.Flags().StringVar(&docListFilerName, "filer-name", "", "Filter by filer name (substring match)")
docListCmd.Flags().StringVar(&docListDocDescription, "doc-description", "", "Filter by document description (substring match)")
docListCmd.Flags().IntVar(&docListRateLimit, "rate-limit", 100, "Rate limit between requests in ms")

docGetCmd.Flags().StringVar(&docGetType, "type", "", "Document type: xbrl, pdf, attach, english, csv (required)")
Expand Down
10 changes: 10 additions & 0 deletions cmd/doc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ func TestDocFinancial_InvalidStatement(t *testing.T) {
expectErrorCode(t, stderr, "VALIDATION_ERROR")
}

func TestDocListCmd_HasDocDescriptionFlag(t *testing.T) {
f := docListCmd.Flags().Lookup("doc-description")
if f == nil {
t.Fatal("doc list command missing --doc-description flag")
}
if f.DefValue != "" {
t.Errorf("--doc-description default = %q, want empty string", f.DefValue)
}
}

func TestDocText_ListSectionsOutput(t *testing.T) {
stdout, _, code := executeCommand("doc", "text", "--list-sections")
if code != 0 {
Expand Down
6 changes: 4 additions & 2 deletions internal/financial/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,11 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes
}
}

// Build summary from current period items and derive metrics
// Build summary from best available period items and derive metrics
summary, summaryPeriod := BuildAndDeriveSummary(statements)
return &ParseResult{
Summary: BuildAndDeriveSummary(statements),
Summary: summary,
SummaryPeriod: summaryPeriod,
Statements: statements,
AccountingStd: acctStd,
Consolidated: hasConsolidatedStmt,
Expand Down
30 changes: 30 additions & 0 deletions internal/financial/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1154,3 +1154,33 @@ func TestDetectAccountingStandard_NeutralOnlyRows_StaysUnknown(t *testing.T) {
t.Errorf("detectAccountingStandard = %q, want %q", std, "unknown")
}
}

// --- Filing-style CSV (IPO prospectus) with Prior1Year contexts ---

func TestParse_FilingStyleCSV_FallbackPeriod(t *testing.T) {
file := makeCSVFile(
"jpcrp020400-srs-001_E41257-000_2025-04-30_01_2026-01-09.csv",
standardHeaders(),
[][]string{
makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "Prior1YearDuration", "前期", "個別", "期間", "JPY", "円", "9426601000"),
makeRow("jpcrp_cor:OrdinaryIncomeSummaryOfBusinessResults", "経常利益、経営指標等", "Prior1YearDuration", "前期", "個別", "期間", "JPY", "円", "1145214000"),
makeRow("jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "総資産額、経営指標等", "Prior1YearInstant", "前期末", "個別", "時点", "JPY", "円", "6160640000"),
makeRow("jpcrp_cor:NetAssetsSummaryOfBusinessResults", "純資産額、経営指標等", "Prior1YearInstant", "前期末", "個別", "時点", "JPY", "円", "4261992000"),
makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "Prior2YearDuration", "前々期", "個別", "期間", "JPY", "円", "8735439000"),
},
)

csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}}
result, err := Parse(csvResult, ParseOpts{})
if err != nil {
t.Fatalf("Parse() error = %v", err)
}

if result.SummaryPeriod != "prior1" {
t.Errorf("SummaryPeriod = %q, want %q", result.SummaryPeriod, "prior1")
}
assertSummaryValue(t, result.Summary, "revenue", 9426601000)
assertSummaryValue(t, result.Summary, "ordinary_income", 1145214000)
assertSummaryValue(t, result.Summary, "total_assets", 6160640000)
assertSummaryValue(t, result.Summary, "net_assets", 4261992000)
}
137 changes: 111 additions & 26 deletions internal/financial/summary.go
Original file line number Diff line number Diff line change
@@ -1,48 +1,133 @@
package financial

import "sort"

// supplementalKeys are summary keys that represent per-share or non-core items.
// A period containing only supplemental keys is not considered a meaningful financial snapshot.
var supplementalKeys = map[string]bool{
"dividend_per_share": true,
"eps": true,
"research_and_development": true,
"shares_outstanding": true,
"treasury_shares": true,
}

// 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 {
// Returns the summary and the period name that was used as the primary data source.
// For annual reports this is typically "current"; for filing documents it may be "prior1" etc.
// An empty string means only filing_date items were available (or no data at all).
func BuildAndDeriveSummary(statements []FinancialStatement) (Summary, string) {
summary := make(Summary)
populateSummary(summary, statements)
period := populateSummary(summary, statements)
DeriveMetrics(summary)
return summary
return summary, period
}

// 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,
}
// additiveKeys are summary keys whose values are summed across multiple line items
// (e.g., short-term + long-term debt), rather than using first-wins.
var additiveKeys = map[string]bool{
"interest_bearing_debt": true,
}

// populateSummary selects the best available period across all statements and extracts
// summary items from it. Filing_date items are always included as supplemental data.
// For annual reports the best period is "current"; for filing documents (IPO prospectuses)
// it falls back to "prior1" etc. Returns the selected period name, or "" if only
// filing_date data was available.
func populateSummary(summary Summary, statements []FinancialStatement) string {
// Phase 1: collect distinct periods and check which ones contain non-supplemental keys.
const estPeriods = 10
seen := make(map[string]bool, estPeriods)
hasCore := make(map[string]bool, estPeriods)
periods := make([]string, 0, estPeriods)

for _, stmt := range statements {
for _, pd := range stmt.Periods {
if pd.Period != "current" && pd.Period != "filing_date" {
if pd.Period == "filing_date" {
continue
}
if !seen[pd.Period] {
seen[pd.Period] = true
periods = append(periods, pd.Period)
}
if hasCore[pd.Period] {
continue // already confirmed core key for this period
}
for _, item := range pd.Items {
if item.SummaryKey == "" || item.Value == nil {
continue
if item.SummaryKey != "" && item.Value != nil && !supplementalKeys[item.SummaryKey] {
hasCore[pd.Period] = true
break
}
}
}
}

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
// Phase 2: sort periods by priority and select bestPeriod.
sort.Slice(periods, func(i, j int) bool {
return periodOrder(periods[i]) < periodOrder(periods[j])
})

bestPeriod := ""
for _, p := range periods {
if hasCore[p] {
bestPeriod = p
break
}
}
// If no period has core keys, fall back to the highest-priority period.
// This handles edge cases like EPS-only or dividend-only statements.
if bestPeriod == "" && len(periods) > 0 {
bestPeriod = periods[0]
}

// Phase 3: extract summary items from bestPeriod, then supplement from filing_date.
// Additive keys (e.g. interest_bearing_debt) are accumulated within each period
// (CL + NCL debt), but once set by one period they are not overwritten by another.
extractPeriod := func(target string) {
// Track which additive keys were already set before this period.
frozenAdditive := make(map[string]bool, len(additiveKeys))
for k := range additiveKeys {
if _, exists := summary[k]; exists {
frozenAdditive[k] = true
}
}

for _, stmt := range statements {
for _, pd := range stmt.Periods {
if pd.Period != target {
continue
}
for _, item := range pd.Items {
if item.SummaryKey == "" || item.Value == nil {
continue
}
} else {
if _, exists := summary[item.SummaryKey]; !exists {
v := *item.Value
summary[item.SummaryKey] = &v
if additiveKeys[item.SummaryKey] {
if frozenAdditive[item.SummaryKey] {
continue // already set by a prior period
}
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
}
}
}
}
}
}

if bestPeriod != "" {
extractPeriod(bestPeriod)
}
extractPeriod("filing_date")

return bestPeriod
}
Loading
Loading