Вы думали, что PowerShell — это только для администрирования серверов? Вовсе нет! Сегодня мы займёмся благородным делом: подсчётом чужих денег. И не где-нибудь, а в официальных документах Комиссии по ценным бумагам и биржам США (SEC). Всё это — из консоли, с каплей Vega и щепоткой аналитического озорства.

Объект нашего любопытства: форма 4, где корпоративные руководители отчитываются о своих сделках с акциями:

Почему нам это интересно? Ну… просто интересно, кто слил акции перед падением цены 😉

Получаем данные

Для этого нам понадобятся две функции PowerShell:

function Get-RecentSecForm4XmlUrls {
    param (
        [string]$CIK = "0000789019",
        [int]$DaysBack = 100
    )

    $headers = @{
        "User-Agent" = "PowerShellScript/1.0 (eosfor@gmail.com)"
        "Accept-Encoding" = "gzip, deflate"
    }

    $url = "https://data.sec.gov/submissions/CIK$CIK.json"
    $data = Invoke-RestMethod -Uri $url -Headers $headers

    $cikTrimmed = $CIK.TrimStart("0")
    $cutoffDate = (Get-Date).AddDays(-$DaysBack)

    $results = @()

    for ($i = 0; $i -lt $data.filings.recent.form.Length; $i++) {
        $formType = $data.filings.recent.form[$i]
        if ($formType -ne "4") { continue }

        $filingDate = Get-Date $data.filings.recent.filingDate[$i]
        if ($filingDate -lt $cutoffDate) { continue }

        $accessionNumber = $data.filings.recent.accessionNumber[$i]
        $primaryDoc = $data.filings.recent.primaryDocument[$i]
        $reportDate = $data.filings.recent.reportDate[$i]

        $folder = $accessionNumber -replace "-", ""
        $xmlFileName = [System.IO.Path]::GetFileNameWithoutExtension($primaryDoc) + ".xml"
        $xmlUrl = "https://www.sec.gov/Archives/edgar/data/$cikTrimmed/$folder/$xmlFileName"

        $results += [PSCustomObject]@{
            FilingDate = $filingDate.ToString("yyyy-MM-dd")
            ReportDate = $reportDate
            XmlUrl     = $xmlUrl
        }
    }

    return $results
}

function Convert-Form4XmlToRecord {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [pscustomobject]$InputObject
    )

    process {
        $headers = @{
            "User-Agent" = "PowerShellScript/1.0 (eosfor@gmail.com)"
        }

        try {
            [xml]$doc = Invoke-WebRequest -Uri $InputObject.XmlUrl -Headers $headers -UseBasicParsing
        }
        catch {
            Write-Warning "Download failed: $($InputObject.XmlUrl)"
            return
        }

        $issuer = $doc.ownershipDocument.issuer.issuerName
        $owner = $doc.ownershipDocument.reportingOwner.reportingOwnerId.rptOwnerName
        $ownerRelationship = $doc.ownershipDocument.reportingOwner.reportingOwnerRelationship

        # Get all role flags where value is '1'
        $relationshipProps = ($ownerRelationship | Get-Member -MemberType Properties | Where-Object {
            $ownerRelationship.$($_.Name) -eq "1"
        }).Name

        # Join multiple roles if needed
        $relationship = if ($relationshipProps.Count -gt 1) {
            $relationshipProps -join ";"
        } else {
            $relationshipProps
        }

        # Собираем footnotes в хештаблицу
        $footnotes = @{}
        if ($doc.ownershipDocument.footnotes -and $doc.ownershipDocument.footnotes.footnote) {
            $rawFootnotes = $doc.ownershipDocument.footnotes.footnote
            if ($rawFootnotes -is [System.Array]) {
                foreach ($f in $rawFootnotes) {
                    $footnotes[$f.id] = $f.'#text' ?? $f.InnerText
                }
            } else {
                $footnotes[$rawFootnotes.id] = $rawFootnotes.'#text' ?? $rawFootnotes.InnerText
            }
        }

        $transactions = $doc.ownershipDocument.nonDerivativeTable.nonDerivativeTransaction
        foreach ($txn in $transactions) {
            $note = $null
            if ($txn.footnoteId) {
                $ids = if ($txn.footnoteId -is [System.Array]) {
                    $txn.footnoteId | ForEach-Object { $_.id }
                } else {
                    @($txn.footnoteId.id)
                }
                $note = ($ids | ForEach-Object { $footnotes[$_] }) -join "; "
            }

            [PSCustomObject]@{
                FilingDate              = $InputObject.FilingDate
                ReportDate              = $InputObject.ReportDate
                Issuer                  = $issuer
                InsiderName             = $owner
                InsiderRole             = $relationship
                SecurityTitle           = $txn.securityTitle.value
                TransactionDate         = $txn.transactionDate.value
                TransactionCode         = $txn.transactionCoding.transactionCode
                SharesTransacted        = $txn.transactionAmounts.transactionShares.value
                PricePerShare           = $txn.transactionAmounts.transactionPricePerShare.value
                SharesOwnedAfterTxn     = $txn.postTransactionAmounts.sharesOwnedFollowingTransaction.value
                OwnershipType           = $txn.ownershipNature.directOrIndirectOwnership.value
                IndirectOwnershipNature = $txn.ownershipNature.natureOfOwnership.value
                Footnote                = $note
                XmlUrl                  = $InputObject.XmlUrl
            }
        }
    }
}

📥 Запустим наш скрипт наблюдения и сохраним данные в переменной $allData. Что-то вроде «проверки биографии», но легально.

$CIKs = "0000789019", "0000320193", "0001318605", "0001288776", "0001352010"
$allData = $CIKs | % { Get-RecentSecForm4XmlUrls -CIK $_  -DaysBack ((Get-Date).DayOfYear) } | Convert-Form4XmlToRecord

🧹 Следующий шаг — навести порядок. Нас интересуют только транзакции, в которых действительно двигались деньги. Если количество акций — 0, пропускаем. Нас интересуют настоящие миллионные сделки (ну или хотя бы что-то приличное).

$data = $allData |
    Select-Object TransactionDate, SharesTransacted, TransactionCode |
    Where-Object { $_.TransactionCode -in @("S", "P", "F", "A", "M", "G") -and $_.SharesTransacted -gt 0 }

$data = $data | ForEach-Object {
    $action = switch ($_.TransactionCode) {
        "S" { "Sell"; break }
        "F" { "Sell"; break }
        "G" { "Sell"; break }
        "A" { "Buy"; break }
        "P" { "Buy"; break }
        "M" { "Buy"; break }
        default { "Other" }
    }

    $_ | Add-Member -NotePropertyName Action -NotePropertyValue $action -Force -PassThru
}

🔧 Чуть не забыли! Чтобы всё это заработало, мы немного доработали dotnet/interactive. Почему? Потому что параметр CustomMimeType в Out-Display… ну, он как бы был, но не совсем работал. Теперь работает — JSON-спеки прямо из ячейки ноутбука, красивые графики. Благодарности можно отправлять автору PR #3671 — то есть мне 😉

Диаграмма рассеяния

Добавим немного интерактива к странице 🙂

📈 Диаграмма рассеяния — визуальный допрос:

Тепловая карта

🔥 Тепловая карта — следи за жаром:

🔍 TransactionCode — расшифровка

Code Что значит Как понимать
A Награда Акции в подарок, обычно бонус. Как подарочная карта, только акциями
S Продажа Продажа акций. Иногда массовая. Часто — перед падением цен
F Налоги Удержание акций для уплаты налогов. Хоть не себе оставили
M Опционы Исполнение опциона. Купить дёшево — продать дорого
G Подарок Подарено. Семье. Трасту. Благотворительности
P Покупка Купили акции на свои. Респект
I Плановая Автоматическая сделка по плану. Легально? Смотря кто спросит
C Конверсия Преобразование деривативов в обычные акции

Пузырьковая диаграмма

🔵 Пузырьковая диаграмма — каждый пузырь — сделка, размер — объём. Чем больше пузырь — тем сочнее сделка: