lib/edgar.ex

defmodule EDGAR do
  use Application

  @edgar_archives_url "https://www.sec.gov/Archives/edgar"
  @edgar_data_url "https://data.sec.gov"
  @edgar_files_url "https://www.sec.gov/files"

  def start(_type, _args) do
    children = [
      {SimpleRateLimiter, interval: 1_000, max: 10}
    ]

    opts = [strategy: :one_for_one, name: EDGAR.Supervisor]
    Supervisor.start_link(children, opts)
  end

  @doc """
  Fetches the entity directory

  ## Required

  * `cik` - The CIK of the entity

  ## Examples

    iex> {:ok, entity_directory} = EDGAR.entity_directory("320193")
    iex> entity_directory["directory"]["name"]
    "/Archives/edgar/data/320193"
  """
  def entity_directory(cik) do
    cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_archives_url}/data/#{cik}/index.json"
    |> get()
  end

  @doc """
  Fetches the filing directory

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing

  ## Examples

    iex> {:ok, filing_directory} = EDGAR.filing_directory("320193", "000032019320000010")
    iex> filing_directory["directory"]["name"]
    "/Archives/edgar/data/320193/000032019320000010"
  """
  def filing_directory(cik, accession_number) do
    accession_number = String.replace(accession_number, "-", "")

    "#{@edgar_archives_url}/data/#{cik}/#{accession_number}/index.json"
    |> get()
  end

  @doc """
  Fetches a list of company tickers

  ## Examples

    iex> {:ok, company_tickers} = EDGAR.company_tickers()
    iex> Enum.count(company_tickers) > 0
    true
  """
  def company_tickers() do
    resp = get("#{@edgar_files_url}/company_tickers.json")

    case resp do
      {:ok, result} ->
        {:ok, Map.values(result)}

      _ ->
        resp
    end
  end

  @doc """
  Fetches a CIK for a given ticker

  ## Required

  * `ticker` - The ticker of the company

  ## Examples

    iex> {:ok, cik} = EDGAR.cik_for_ticker("AAPL")
    iex> cik
    "320193"
  """
  def cik_for_ticker(ticker) do
    ticker = String.upcase(ticker)

    case company_tickers() do
      {:ok, tickers} ->
        ticker_data = Enum.find(tickers, fn t -> t["ticker"] == ticker end)

        case ticker_data do
          nil ->
            {:error, :not_found}

          _ ->
            {:ok, Integer.to_string(ticker_data["cik_str"])}
        end

      {:error, _} = error ->
        error
    end
  end

  @doc """
  Fetches submissions for a given CIK

  ## Required

  * `cik` - The CIK of the entity

  ## Examples

    iex> {:ok, submissions} = EDGAR.submissions("320193")
    iex> submissions["cik"]
    "320193"
  """
  def submissions(cik) do
    cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/submissions/CIK#{cik}.json"
    |> get()
  end

  @doc """
  Fetches company facts for a given CIK

  ## Required

  * `cik` - The CIK of the entity

  ## Examples

    iex> {:ok, company_facts} = EDGAR.company_facts("320193")
    iex> company_facts["cik"]
    320193
  """
  def company_facts(cik) do
    cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/api/xbrl/companyfacts/CIK#{cik}.json"
    |> get()
  end

  @doc """
  Fetches company concepts for a given CIK and concept (taxonomy, tag)

  ## Required

  * `cik` - The CIK of the entity
  * `taxonomy` - The taxonomy of the concept
  * `tag` - The tag of the concept

  ## Examples

    iex> {:ok, company_concept} = EDGAR.company_concept("320193", "us-gaap", "AccountsPayableCurrent")
    iex> company_concept["cik"]
    320193
  """
  def company_concept(cik, taxonomy, tag) do
    cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/api/xbrl/companyconcept/CIK#{cik}/#{taxonomy}/#{tag}.json"
    |> get()
  end

  @doc """
  Fetches frames for a given taxonomy, concept, unit, and period

  ## Required

  * `taxonomy` - The taxonomy of the concept
  * `tag` - The tag of the concept
  * `unit` - The unit of the concept
  * `period` - The period of the concept

  ## Examples

    iex> {:ok, frames} = EDGAR.frames("us-gaap", "AccountsPayableCurrent", "USD", "CY2019Q1I")
    iex> frames["tag"]
    "AccountsPayableCurrent"
  """
  def frames(taxonomy, tag, unit, period) do
    "#{@edgar_data_url}/api/xbrl/frames/#{taxonomy}/#{tag}/#{unit}/#{period}.json"
    |> get()
  end

  @doc """
  Fetches a list of filings from the submissions file

  ## Required

  * `cik` - The CIK of the entity

  ## Examples

    iex> {:ok, filings} = EDGAR.filings("320193")
    iex> Enum.count(filings) > 0
    true
  """
  def filings(cik) do
    case submissions(cik) do
      {:ok, submissions} ->
        recent_filings = submissions["filings"]["recent"]

        formatted_recent_filings = format_filings(recent_filings)

        files = submissions["filings"]["files"]

        formatted_file_filings =
          Enum.flat_map(files, fn file ->
            file_name = file["name"]
            {:ok, file_data} = get("#{@edgar_data_url}/submissions/#{file_name}")
            format_filings(file_data)
          end)

        {:ok, formatted_recent_filings ++ formatted_file_filings}

      {:error, _} = error ->
        error
    end
  end

  @doc false
  defp format_filings(filings) do
    field_names = [
      "acceptanceDateTime",
      "accessionNumber",
      "act",
      "fileNumber",
      "form",
      "isInlineXBRL",
      "isXBRL",
      "items",
      "primaryDocDescription",
      "primaryDocument",
      "reportDate",
      "size"
    ]

    file_field_values = for name <- field_names, do: Map.get(filings, name)

    Enum.zip(file_field_values)
    |> Enum.map(fn tuple ->
      Map.new(Enum.zip(field_names, Tuple.to_list(tuple)))
    end)
  end

  @doc """
  Fetches a list of filings from the submissions file by form

  ## Required

  * `cik` - The CIK of the entity
  * `forms` - The forms to filter by

  ## Examples

    iex> {:ok, filings} = EDGAR.filings_by_forms("320193", ["10-K", "10-Q"])
    iex> Enum.count(filings) > 0
    true
  """
  def filings_by_forms(cik, forms) do
    case filings(cik) do
      {:ok, filings} ->
        {:ok, Enum.filter(filings, fn filing -> filing["form"] in forms end)}

      {:error, _} = error ->
        error
    end
  end

  @doc """
  Parses a 13F filing primary document

  ## Required

  * `document` - The document to parse

  """
  def parse_13f_document(document) do
    EDGAR.Native.parse_13f_document(document)
  end

  @doc """

  Parses a 13F filing table

  ## Required

  * `table` - The table to parse

  """
  def parse_13f_table(table) do
    EDGAR.Native.parse_13f_table(table)
  end

  @doc false
  defp get(url) do
    SimpleRateLimiter.wait_and_proceed(fn ->
      user_agent =
        Application.get_env(:edgar_client, :user_agent, "default <default@default.com>")

      resp =
        HTTPoison.get(url, [{"User-Agent", user_agent}], follow_redirect: true)

      case resp do
        {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
          {:ok, Jason.decode!(body)}

        {:ok, %HTTPoison.Response{status_code: 404}} ->
          {:error, :not_found}

        {:ok, %HTTPoison.Response{status_code: code}} ->
          {:error, {:unexpected_status_code, code}}

        {:error, %HTTPoison.Error{reason: reason}} ->
          {:error, reason}
      end
    end)
  end
end