#requires -Version 5.1 [CmdletBinding()] param( [ValidateSet('Inventory', 'Migrate', 'Integrate', 'Build', 'Replay', 'Recent', 'All')] [string]$Phase = 'Inventory', [string]$ManifestPath = '', [string]$BotPath = '', [double]$FloatTolerance = 1e-9, [ValidateRange(0, 10)] [int]$MaxRepairAttempts = 3, [ValidateRange(0, 5)] [int]$MaxCodexValidationAttempts = 2, [ValidateSet('low', 'medium', 'high')] [string]$CodexReasoning = 'medium', [string]$DatabaseUrl = $env:BANBOT_DATABASE_URL, [string]$DatabaseOverlay = '', [ValidateSet('', 'timescale', 'questdb')] [string]$DatabaseType = '', [ValidateRange(1, 1440)] [int]$BacktestTimeoutMinutes = 120, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) if (-not $ManifestPath) { $ManifestPath = Join-Path $script:RepoRoot 'code\migration_manifest.json' } if (-not $BotPath) { $BotPath = Join-Path $script:RepoRoot 'bot.exe' } $script:ManifestPath = [IO.Path]::GetFullPath($ManifestPath) $script:Manifest = Get-Content -LiteralPath $script:ManifestPath -Raw | ConvertFrom-Json $script:StatePath = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.stateFile)) $script:SummaryPath = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.summaryFile)) $script:WorkRoot = Join-Path $script:RepoRoot 'code\.migration-work' $script:LogRoot = Join-Path $script:RepoRoot 'code\migration-logs' $script:RecentToday = [DateTime]::UtcNow.Date $script:AllReportPolicies = @() $script:UncoveredPolicies = @() function Write-JsonAtomic { param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path) $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Force -Path $parent | Out-Null $temp = "$Path.tmp" $Value | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $temp -Encoding UTF8 Move-Item -LiteralPath $temp -Destination $Path -Force } function Get-RelativePath { param([Parameter(Mandatory)][string]$Base, [Parameter(Mandatory)][string]$Path) $baseFull = [IO.Path]::GetFullPath($Base).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $pathFull = [IO.Path]::GetFullPath($Path) $baseUri = New-Object Uri($baseFull) $pathUri = New-Object Uri($pathFull) return [Uri]::UnescapeDataString($baseUri.MakeRelativeUri($pathUri).ToString()).Replace('/', [IO.Path]::DirectorySeparatorChar) } function Assert-DescendantPath { param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Path) $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $pathFull = [IO.Path]::GetFullPath($Path) if (-not $pathFull.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe path outside expected root: $pathFull" } return $pathFull } function Assert-NoReparsePath { param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Path) $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') $pathFull = Assert-DescendantPath -Root $rootFull -Path $Path $relative = $pathFull.Substring($rootFull.Length).TrimStart('\', '/') $current = $rootFull foreach ($part in @($relative -split '[\\/]' | Where-Object { $_ })) { $current = Join-Path $current $part if (Test-Path -LiteralPath $current) { $item = Get-Item -LiteralPath $current -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse-point path: $current" } } } return $pathFull } function Read-State { if (Test-Path -LiteralPath $script:StatePath) { return Get-Content -LiteralPath $script:StatePath -Raw | ConvertFrom-Json } return [pscustomobject]@{ schemaVersion = 1 manifest = $script:ManifestPath completed = @() updatedUtc = [DateTime]::UtcNow.ToString('o') } } function Test-Completed { param([Parameter(Mandatory)][string]$Key) $state = Read-State return @($state.completed) -contains $Key } function Set-Completed { param([Parameter(Mandatory)][string]$Key) $state = Read-State $completed = @($state.completed) if ($completed -notcontains $Key) { $completed += $Key } $state.completed = @($completed | Sort-Object -Unique) $state.updatedUtc = [DateTime]::UtcNow.ToString('o') Write-JsonAtomic -Value $state -Path $script:StatePath } function Invoke-LoggedCommand { param( [Parameter(Mandatory)][string]$FilePath, [Parameter(Mandatory)][string[]]$Arguments, [Parameter(Mandatory)][string]$LogPath, [string]$InputText = '', [string]$WorkingDirectory = $script:RepoRoot, [ValidateRange(1, 86400)][int]$TimeoutSeconds = 3600 ) New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null if (-not [IO.Path]::IsPathRooted($FilePath)) { $application = Get-Command $FilePath -CommandType Application -ErrorAction SilentlyContinue | Where-Object { $_.Source -match '\.(?:cmd|bat|com|exe)$' } | Select-Object -First 1 if ($null -eq $application) { throw "Executable not found: $FilePath" } $FilePath = $application.Source } $quotedArgs = foreach ($argument in $Arguments) { $escaped = [regex]::Replace([string]$argument, '(\\*)"', '$1$1\"') $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') '"' + $escaped + '"' } $startInfo = New-Object Diagnostics.ProcessStartInfo if ([IO.Path]::GetExtension($FilePath) -match '^\.(?:cmd|bat)$') { $startInfo.FileName = $env:ComSpec $startInfo.Arguments = '/d /s /c ""' + $FilePath + '" ' + ($quotedArgs -join ' ') + '"' } else { $startInfo.FileName = $FilePath $startInfo.Arguments = $quotedArgs -join ' ' } $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.RedirectStandardInput = $true $utf8NoBom = New-Object Text.UTF8Encoding($false) if ($startInfo.PSObject.Properties.Name -contains 'StandardInputEncoding') { $startInfo.StandardInputEncoding = $utf8NoBom } if ($startInfo.PSObject.Properties.Name -contains 'StandardOutputEncoding') { $startInfo.StandardOutputEncoding = $utf8NoBom } if ($startInfo.PSObject.Properties.Name -contains 'StandardErrorEncoding') { $startInfo.StandardErrorEncoding = $utf8NoBom } $process = New-Object Diagnostics.Process $process.StartInfo = $startInfo try { if (-not $process.Start()) { throw "Failed to start $FilePath" } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if ($InputText) { $inputBytes = [Text.Encoding]::UTF8.GetBytes($InputText) $process.StandardInput.BaseStream.Write($inputBytes, 0, $inputBytes.Length) $process.StandardInput.BaseStream.Flush() } $process.StandardInput.BaseStream.Close() $timedOut = -not $process.WaitForExit($TimeoutSeconds * 1000) if ($timedOut) { try { $process.Kill() } catch { } $process.WaitForExit() } $stdout = $stdoutTask.Result $stderr = $stderrTask.Result $exitCode = $process.ExitCode $output = @($stdout, $stderr) | Where-Object { $_ } ($output -join [Environment]::NewLine) | Set-Content -LiteralPath $LogPath -Encoding UTF8 if ($timedOut) { throw "Command timed out after $TimeoutSeconds seconds: $FilePath" } } finally { $process.Dispose() } return [pscustomobject]@{ ExitCode = $exitCode; Output = @($output); LogPath = $LogPath } } function Get-ReportRoot { param([Parameter(Mandatory)]$Report) return [IO.Path]::GetFullPath((Join-Path $script:Manifest.reportsRoot $Report.id)) } function Get-ConfigPolicyNames { param([Parameter(Mandatory)][string]$Path) $inside = $false $names = New-Object Collections.Generic.List[string] foreach ($line in Get-Content -LiteralPath $Path) { if ($line -match '^run_policy\s*:') { $inside = $true continue } if ($inside -and $line -match '^\S') { $inside = $false } if ($inside -and $line -match '^\s{2}-\s+name:\s*(?[^#\s]+)') { $names.Add($Matches.name) } } return @($names | Sort-Object -Unique) } function Get-PolicyParts { param([Parameter(Mandatory)][string]$Name) $parts = $Name.Split(':', 2) if ($parts.Count -eq 1) { return [pscustomobject]@{ Group = $Name; Strategy = $Name } } return [pscustomobject]@{ Group = $parts[0]; Strategy = $parts[1] } } function Get-TargetSourceFiles { param([Parameter(Mandatory)][string]$ReportRoot, [Parameter(Mandatory)][string[]]$Policies) $files = New-Object Collections.Generic.List[IO.FileInfo] foreach ($group in @($Policies | ForEach-Object { (Get-PolicyParts $_).Group } | Sort-Object -Unique)) { $groupDir = Join-Path $ReportRoot "strat_$group" if (-not (Test-Path -LiteralPath $groupDir -PathType Container)) { return @() } if ($group -eq 'llmjob') { $main = Join-Path $groupDir 'main.go' if (Test-Path -LiteralPath $main -PathType Leaf) { $files.Add((Get-Item -LiteralPath $main)) } foreach ($policy in @($Policies | Where-Object { (Get-PolicyParts $_).Group -eq $group })) { $strategy = (Get-PolicyParts $policy).Strategy $relative = if ($strategy.StartsWith('H', [StringComparison]::Ordinal) -and $strategy.Length -gt 1) { Join-Path 'his' ($strategy.Substring(1) + '.go') } else { $strategy + '.go' } $source = Join-Path $groupDir $relative if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { return @() } $files.Add((Get-Item -LiteralPath $source)) } } else { foreach ($file in Get-ChildItem -LiteralPath $groupDir -File -Filter '*.go' -Recurse | Where-Object Name -notlike '*_test.go') { $files.Add($file) } } } return @($files | Sort-Object FullName -Unique | ForEach-Object { (Get-RelativePath -Base $ReportRoot -Path $_.FullName).Replace('\', '/') }) } function New-AutoRepresentative { param([Parameter(Mandatory)][IO.DirectoryInfo]$Directory, [Parameter(Mandatory)][string[]]$Policies) $sources = @(Get-TargetSourceFiles -ReportRoot $Directory.FullName -Policies $Policies) if ($sources.Count -eq 0) { return $null } $groupName = "migrated_$($Directory.Name)" $policyMap = [ordered]@{} foreach ($policy in $Policies) { $policyMap[$policy] = "${groupName}:$((Get-PolicyParts $policy).Strategy)" } $target = [ordered]@{ id = $Directory.Name sourceFiles = $sources generatedGroup = $groupName policyMap = [pscustomobject]$policyMap } $configPath = Join-Path $Directory.FullName 'config.yml' if (Select-String -LiteralPath $configPath -Pattern '^\s+use_latest\s*:\s*true\s*(?:#.*)?$' -Quiet) { $target.dynamicUniverse = [pscustomobject]@{ source = 'pairmgr/use_latest'; sourceLog = 'out.log' } } return [pscustomobject]$target } function Resolve-Representatives { $resolved = New-Object Collections.Generic.List[object] $covered = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) foreach ($report in @($script:Manifest.representatives)) { $resolved.Add($report) foreach ($policy in $report.policyMap.psobject.Properties.Name) { $null = $covered.Add($policy) } } $root = [IO.Path]::GetFullPath($script:Manifest.reportsRoot) $candidates = New-Object Collections.Generic.List[object] $allPolicies = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) foreach ($directory in Get-ChildItem -LiteralPath $root -Directory | Sort-Object Name) { $configPath = Join-Path $directory.FullName 'config.yml' $detailPath = Join-Path $directory.FullName 'detail.json' if (-not (Test-Path -LiteralPath $configPath -PathType Leaf) -or -not (Test-Path -LiteralPath $detailPath -PathType Leaf)) { continue } $policies = @(Get-ConfigPolicyNames -Path $configPath) if ($policies.Count -eq 0) { continue } foreach ($policy in $policies) { $null = $allPolicies.Add($policy) } if (@($script:Manifest.representatives.id) -contains $directory.Name) { continue } $candidate = New-AutoRepresentative -Directory $directory -Policies $policies if ($null -ne $candidate) { $candidates.Add($candidate) } } while (@($allPolicies | Where-Object { -not $covered.Contains($_) }).Count -gt 0) { $best = $null $bestNew = @() foreach ($candidate in $candidates) { $newPolicies = @($candidate.policyMap.psobject.Properties.Name | Where-Object { -not $covered.Contains($_) }) if ($newPolicies.Count -gt $bestNew.Count) { $best = $candidate $bestNew = $newPolicies } } if ($null -eq $best -or $bestNew.Count -eq 0) { $missing = @($allPolicies | Where-Object { -not $covered.Contains($_) } | Sort-Object) $script:UncoveredPolicies = $missing Write-Warning "Skipping policies with no report source snapshot: $($missing -join ', ')" break } $resolved.Add($best) foreach ($policy in $bestNew) { $null = $covered.Add($policy) } $remaining = @($candidates | Where-Object id -ne $best.id) $candidates = New-Object Collections.Generic.List[object] foreach ($candidate in $remaining) { $candidates.Add($candidate) } } $script:AllReportPolicies = @($allPolicies | Sort-Object) if ($null -eq $script:UncoveredPolicies) { $script:UncoveredPolicies = @() } return @($resolved | ForEach-Object { $_ }) } function Assert-RepresentativeInputs { param([Parameter(Mandatory)]$Report) $reportRoot = Get-ReportRoot $Report foreach ($required in @('config.yml', 'detail.json')) { $path = Join-Path $reportRoot $required if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing $required for report $($Report.id): $path" } } foreach ($source in @($Report.sourceFiles)) { $path = Join-Path $reportRoot $source Assert-DescendantPath -Root $reportRoot -Path $path | Out-Null if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing allowlisted source for report $($Report.id): $source" } } } function Get-SourceFingerprint { param([Parameter(Mandatory)][string]$ReportRoot, [Parameter(Mandatory)][AllowEmptyCollection()][IO.FileInfo[]]$Files) if ($Files.Count -eq 0) { return $null } $rows = foreach ($file in $Files) { $relative = (Get-RelativePath -Base $ReportRoot -Path $file.FullName).Replace('\', '/') "$relative|$((Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash)" } $payload = [Text.Encoding]::UTF8.GetBytes((($rows | Sort-Object) -join "`n")) $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha.ComputeHash($payload))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Invoke-Inventory { $root = [IO.Path]::GetFullPath($script:Manifest.reportsRoot) $reportDirs = @(Get-ChildItem -LiteralPath $root -Directory) $allDirs = @(Get-ChildItem -LiteralPath $root -Directory -Recurse) $configFiles = @(Get-ChildItem -LiteralPath $root -File -Filter 'config.yml' -Recurse) $detailFiles = @(Get-ChildItem -LiteralPath $root -File -Filter 'detail.json' -Recurse) $sourceFiles = @(Get-ChildItem -LiteralPath $root -File -Filter '*.go' -Recurse | Where-Object { $_.FullName -match '[\\/]strat_[^\\/]+[\\/]' }) $reports = foreach ($dir in $reportDirs) { $sources = @(Get-ChildItem -LiteralPath $dir.FullName -File -Filter '*.go' -Recurse | Where-Object { $_.FullName -match '[\\/]strat_[^\\/]+[\\/]' }) [pscustomobject]@{ id = $dir.Name configCount = @(Get-ChildItem -LiteralPath $dir.FullName -File -Filter 'config.yml' -Recurse).Count detailCount = @(Get-ChildItem -LiteralPath $dir.FullName -File -Filter 'detail.json' -Recurse).Count sourceCount = $sources.Count sourceFingerprint = Get-SourceFingerprint -ReportRoot $dir.FullName -Files $sources } } $duplicates = @($reports | Where-Object sourceFingerprint | Group-Object sourceFingerprint | Where-Object Count -gt 1 | ForEach-Object { [pscustomobject]@{ fingerprint = $_.Name count = $_.Count reports = @($_.Group.id | Sort-Object) } }) $inventory = [pscustomobject]@{ schemaVersion = 1 generatedUtc = [DateTime]::UtcNow.ToString('o') root = $root counts = [pscustomobject]@{ reportDirectories = $reportDirs.Count allDirectories = $allDirs.Count configFiles = $configFiles.Count detailFiles = $detailFiles.Count strategySourceFiles = $sourceFiles.Count duplicateFingerprints = $duplicates.Count } policyCoverage = [pscustomobject]@{ uniquePolicies = $script:AllReportPolicies.Count coveredPolicies = @($script:Representatives | ForEach-Object { $_.policyMap.psobject.Properties.Name } | Sort-Object -Unique).Count missingSourcePolicies = @($script:UncoveredPolicies) representativeReports = $script:Representatives.Count } representatives = @($script:Representatives.id) migrationTargets = @($script:Representatives | ForEach-Object { [pscustomobject]@{ id = $_.id policies = @($_.policyMap.psobject.Properties.Name) generatedGroup = $_.generatedGroup sourceFiles = @($_.sourceFiles) dynamicUniverse = if ($_.psobject.Properties['dynamicUniverse']) { $_.dynamicUniverse } else { $null } } }) duplicateFingerprints = $duplicates reports = @($reports) } $path = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.inventoryFile)) Write-JsonAtomic -Value $inventory -Path $path Set-Completed 'inventory' Write-Host "Inventory written: $path" Write-Host "Reports=$($reportDirs.Count) configs=$($configFiles.Count) details=$($detailFiles.Count) sources=$($sourceFiles.Count) duplicateSets=$($duplicates.Count)" } function Copy-MigrationInputs { param([Parameter(Mandatory)]$Report, [Parameter(Mandatory)][string]$Scratch) if (Test-Path -LiteralPath $Scratch) { Assert-DescendantPath -Root $script:WorkRoot -Path $Scratch | Out-Null Remove-Item -LiteralPath $Scratch -Recurse -Force } New-Item -ItemType Directory -Force -Path (Join-Path $Scratch 'input'), (Join-Path $Scratch 'output') | Out-Null Copy-Item -LiteralPath $script:Manifest.knowledgeFile -Destination (Join-Path $Scratch 'input\banbot_cn.md') $reportRoot = Get-ReportRoot $Report foreach ($relative in @($Report.sourceFiles)) { $destination = Join-Path (Join-Path $Scratch 'input\sources') $relative New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null Copy-Item -LiteralPath (Join-Path $reportRoot $relative) -Destination $destination } } function Get-PolicyInstructions { param([Parameter(Mandatory)]$Report) return @($Report.policyMap.psobject.Properties | ForEach-Object { "- $($_.Name) -> $($_.Value)" }) -join "`n" } function Get-InlineFileBlock { param([Parameter(Mandatory)][string]$Label, [Parameter(Mandatory)][string]$Path) $content = Get-Content -LiteralPath $Path -Raw return "<<>>`n$content`n<<>>" } function Write-CodexFilesSchema { param([Parameter(Mandatory)][string]$Path) $schema = @' { "type": "object", "properties": { "files": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"], "additionalProperties": false } } }, "required": ["files"], "additionalProperties": false } '@ [IO.File]::WriteAllText($Path, $schema, (New-Object Text.UTF8Encoding($false))) } function Write-CodexResponseFiles { param( [Parameter(Mandatory)][string]$ResponsePath, [Parameter(Mandatory)][string]$Destination ) try { $response = Get-Content -LiteralPath $ResponsePath -Raw | ConvertFrom-Json } catch { throw "Codex response is not valid JSON: ${ResponsePath}: $($_.Exception.Message)" } $files = @($response.files) if ($files.Count -eq 0) { throw "Codex response contains no files: $ResponsePath" } New-Item -ItemType Directory -Force -Path $Destination | Out-Null foreach ($file in $files) { $relative = ([string]$file.path).Replace('/', [IO.Path]::DirectorySeparatorChar) if ([string]::IsNullOrWhiteSpace($relative) -or [IO.Path]::IsPathRooted($relative) -or $relative -match '(^|[\\/])\.\.([\\/]|$)' -or [IO.Path]::GetExtension($relative) -ne '.go') { throw "Unsafe or non-Go path in Codex response: $relative" } $target = [IO.Path]::GetFullPath((Join-Path $Destination $relative)) Assert-DescendantPath -Root $Destination -Path $target | Out-Null New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null Set-Content -LiteralPath $target -Value ([string]$file.content) -Encoding UTF8 } } function Assert-GeneratedPackage { param([Parameter(Mandatory)]$Report, [Parameter(Mandatory)][string]$Directory) $files = @(Get-ChildItem -LiteralPath $Directory -File -Filter '*.go' -Recurse) if ($files.Count -eq 0) { throw "Codex produced no Go files for $($Report.id)" } $text = ($files | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" if ($text -match '\bOn(?:Info)?Bar\s*:') { throw "Generated package still uses deprecated OnBar/OnInfoBar callbacks for $($Report.id)" } if ($text -notmatch '\bOnData\s*:') { throw "Generated package does not implement OnData for $($Report.id)" } if ($Report.psobject.Properties['requiresRouteData'] -and $Report.requiresRouteData -and $text -notmatch '\bRouteData\s*\(') { throw "Generated package must use RouteData for mixed main/info callbacks in $($Report.id)" } if ($text -notmatch [regex]::Escape("AddStratGroup(`"$($Report.generatedGroup)`"")) { throw "Generated package does not register group $($Report.generatedGroup)" } foreach ($policy in $Report.policyMap.psobject.Properties) { $strategyName = $policy.Value.Split(':', 2)[1] if ($text -notmatch ('["'']' + [regex]::Escape($strategyName) + '["'']\s*:')) { throw "Generated package does not register strategy key $strategyName" } } $directoryFull = [IO.Path]::GetFullPath($Directory) Assert-DescendantPath -Root $script:RepoRoot -Path $directoryFull | Out-Null $relative = (Get-RelativePath -Base $script:RepoRoot -Path $directoryFull).Replace('\', '/') $compile = Invoke-LoggedCommand -FilePath 'go' -Arguments @('test', "./$relative") -LogPath (Join-Path $script:LogRoot "go-test-generated-$($Report.id).log") -TimeoutSeconds 300 if ($compile.ExitCode -ne 0) { throw "Generated package does not compile for $($Report.id); see $($compile.LogPath)" } } function Invoke-CodexMigration { param([Parameter(Mandatory)]$Report) Assert-RepresentativeInputs $Report $scratch = Join-Path $script:WorkRoot $Report.id Copy-MigrationInputs -Report $Report -Scratch $scratch $blocks = New-Object Collections.Generic.List[string] $blocks.Add((Get-InlineFileBlock -Label 'banbot_cn.md' -Path (Join-Path $scratch 'input\banbot_cn.md'))) foreach ($relative in @($Report.sourceFiles)) { $blocks.Add((Get-InlineFileBlock -Label $relative.Replace('\', '/') -Path (Join-Path (Join-Path $scratch 'input\sources') $relative))) } $inlineInputs = $blocks -join "`n`n" $prompt = @" Do not call tools or inspect the filesystem. All allowed inputs are included below. Return only JSON matching the supplied schema. Each file path must be relative and end in .go. Generate the complete compilable Go package represented by those files. Preserve trading behavior. Convert deprecated OnBar/OnInfoBar behavior to OnData using the documented main/info-data helpers so info data cannot execute main-bar logic twice. The package must register exactly this unique group: $($Report.generatedGroup) Register these policy mappings: $(Get-PolicyInstructions $Report) Use package name report_$($Report.id). Remove registrations and code for strategies outside the mappings above. Resolve dependencies from the allowlisted files without importing the old banstagy strategy package. Do not add dependencies. Do not include markdown fences or explanations. $inlineInputs "@ $logPath = Join-Path $script:LogRoot "codex-migrate-$($Report.id).log" $schemaPath = Join-Path $scratch 'files-schema.json' $responsePath = Join-Path $scratch 'files-response.json' Write-CodexFilesSchema -Path $schemaPath $reasoningConfig = 'model_reasoning_effort="' + $CodexReasoning + '"' $result = Invoke-LoggedCommand -FilePath 'codex' -Arguments @('exec', '--disable', 'hooks', '--disable', 'multi_agent', '-c', $reasoningConfig, '--sandbox', 'read-only', '--ephemeral', '--skip-git-repo-check', '--output-schema', $schemaPath, '--output-last-message', $responsePath, '-C', $scratch, '-') -LogPath $logPath -InputText $prompt -WorkingDirectory $scratch if ($result.ExitCode -ne 0) { throw "Codex migration failed for $($Report.id); see $logPath" } $output = Join-Path $scratch 'output' Write-CodexResponseFiles -ResponsePath $responsePath -Destination $output $formatArgs = @('-w') + @(Get-ChildItem -LiteralPath $output -File -Filter '*.go' -Recurse | Select-Object -ExpandProperty FullName) $format = Invoke-LoggedCommand -FilePath 'gofmt' -Arguments $formatArgs -LogPath (Join-Path $script:LogRoot "gofmt-migrate-$($Report.id).log") if ($format.ExitCode -ne 0) { throw "gofmt failed for $($Report.id); see $($format.LogPath)" } Assert-GeneratedPackage -Report $Report -Directory $output $generatedRoot = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.generatedRoot)) $destination = Join-Path $generatedRoot $Report.id if (Test-Path -LiteralPath $destination) { Assert-DescendantPath -Root $generatedRoot -Path $destination | Out-Null Remove-Item -LiteralPath $destination -Recurse -Force } New-Item -ItemType Directory -Force -Path $destination | Out-Null Copy-Item -Path (Join-Path $output '*') -Destination $destination -Recurse Set-Completed "migrate:$($Report.id)" Write-Host "Migrated $($Report.id) -> $destination" } function Invoke-Migrate { foreach ($report in $script:Representatives) { $destination = Join-Path (Join-Path $script:RepoRoot $script:Manifest.generatedRoot) $report.id if (-not $Force -and (Test-Completed "migrate:$($report.id)") -and (Test-Path -LiteralPath $destination)) { Write-Host "Resume: migration already complete for $($report.id)" continue } Invoke-CodexMigration -Report $report } } function Invoke-Integrate { foreach ($report in $script:Representatives) { $destination = Join-Path (Join-Path $script:RepoRoot $script:Manifest.generatedRoot) $report.id Assert-GeneratedPackage -Report $report -Directory $destination } $imports = @($script:Representatives | Sort-Object id | ForEach-Object { "`t_ `"github.com/banbox/banstagy/code/generated/$($_.id)`"" }) -join "`r`n" $registry = @" // Code generated by scripts/migrate_backtests.ps1; DO NOT EDIT. package code import ( $imports ) "@ $registryPath = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.registryFile)) New-Item -ItemType Directory -Force -Path (Split-Path -Parent $registryPath) | Out-Null Set-Content -LiteralPath $registryPath -Value $registry -Encoding UTF8 $mainPath = Join-Path $script:RepoRoot 'main.go' $mainText = Get-Content -LiteralPath $mainPath -Raw $importLine = "`t_ `"github.com/banbox/banstagy/code`"" if ($mainText -notmatch [regex]::Escape('github.com/banbox/banstagy/code')) { $mainText = [regex]::Replace($mainText, 'import \(\r?\n', "import (`r`n$importLine`r`n", 1) Set-Content -LiteralPath $mainPath -Value $mainText -Encoding UTF8 } $formatLog = Join-Path $script:LogRoot 'gofmt-integrate.log' $format = Invoke-LoggedCommand -FilePath 'gofmt' -Arguments @('-w', $registryPath, $mainPath) -LogPath $formatLog if ($format.ExitCode -ne 0) { throw "gofmt failed; see $formatLog" } Set-Completed 'integrate' Write-Host "Generated registry and integrated blank import in main.go" } function Invoke-Build { $botFull = [IO.Path]::GetFullPath($BotPath) $result = Invoke-LoggedCommand -FilePath 'go' -Arguments @('build', '-o', $botFull, '.') -LogPath (Join-Path $script:LogRoot 'build.log') if ($result.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $botFull)) { throw "Build failed; see $($result.LogPath)" } Set-Completed 'build' Write-Host "Build succeeded: $botFull" } function Remove-YamlTopLevelBlock { param([string[]]$Lines, [Parameter(Mandatory)][string]$Name) $result = New-Object Collections.Generic.List[string] $skipping = $false foreach ($line in $Lines) { if ($line -match ('^' + [regex]::Escape($Name) + '\s*:')) { $skipping = $true continue } if ($skipping -and $line -match '^\S') { $skipping = $false } if (-not $skipping) { $result.Add($line) } } return @($result) } function Set-YamlTopLevelScalar { param([string[]]$Lines, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Value) $filtered = @(Remove-YamlTopLevelBlock -Lines $Lines -Name $Name) return @($filtered) + @("${Name}: $Value") } function Get-LegacyUniverseFromLog { param([Parameter(Mandatory)]$Report) $spec = $Report.dynamicUniverse $logPath = Join-Path (Get-ReportRoot $Report) ([string]$spec.sourceLog) $matches = New-Object Collections.Generic.List[object] foreach ($line in Get-Content -LiteralPath $logPath) { if ($line -match 'USDT\((?\d+)\):\s+(?[A-Z0-9]+(?:\s+[A-Z0-9]+)*)\s*$') { $bases = @($Matches.bases -split '\s+' | Where-Object { $_ }) if ($bases.Count -eq [int]$Matches.count) { $matches.Add([pscustomobject]@{ Count = $bases.Count; Bases = $bases }) } } } if ($spec.psobject.Properties['expectedWarmupPairs']) { $filtered = @($matches | Where-Object Count -eq [int]$spec.expectedWarmupPairs) $matches = New-Object Collections.Generic.List[object] foreach ($match in $filtered) { $matches.Add($match) } } $selected = @($matches | Sort-Object Count -Descending | Select-Object -First 1) if ($selected.Count -gt 0) { return @($selected[0].Bases | ForEach-Object { "$_/USDT:USDT" }) } $configPath = Join-Path (Get-ReportRoot $Report) 'config.yml' foreach ($line in Get-Content -LiteralPath $configPath) { if ($line -match '^pairs\s*:\s*\[(?[^]]+)\]') { $pairs = @($Matches.pairs -split ',' | ForEach-Object { $_.Trim().Trim("'", '"') } | Where-Object { $_ }) if ($pairs.Count -gt 0) { return @($pairs | ForEach-Object { if ($_ -match '/') { $_ } else { "$_/USDT:USDT" } }) } } } throw "Could not recover the legacy universe from $logPath or top-level pairs in $configPath" } function ConvertTo-SanitizedConfig { param([Parameter(Mandatory)]$Report, [switch]$PinLegacyUniverse) $sourcePath = Join-Path (Get-ReportRoot $Report) 'config.yml' $lines = @(Get-Content -LiteralPath $sourcePath) foreach ($block in @('api_server', 'rpc_channels', 'webhook', 'mail', 'llm_models')) { $lines = @(Remove-YamlTopLevelBlock -Lines $lines -Name $block) } $sensitive = '(?i)^(?\s*)(?api_key|api_secret|secret|token|password|passwd|pwd|jwt_secret_key|corp_secret|private_key|url)\s*:' $lines = @($lines | ForEach-Object { if ($_ -match $sensitive) { "$($Matches.indent)$($Matches.key): `"`"" } else { $_ } }) foreach ($mapping in $Report.policyMap.psobject.Properties) { $old = [regex]::Escape($mapping.Name) $replacement = [string]$mapping.Value $pattern = "^(?\s*(?:-\s*)?name:\s*)$old(?\s*(?:#.*)?)$" $lines = @($lines | ForEach-Object { if ($_ -match $pattern) { "$($Matches.prefix)$replacement$($Matches.suffix)" } else { $_ } }) } $detail = Get-Content -LiteralPath (Join-Path (Get-ReportRoot $Report) 'detail.json') -Raw | ConvertFrom-Json foreach ($field in @('timerange', 'time_start', 'time_end')) { $lines = @(Remove-YamlTopLevelBlock -Lines $lines -Name $field) } $lines += @("time_start: '$([int64]$detail.startMS)'", "time_end: '$([int64]$detail.endMS)'") if ($PinLegacyUniverse -and $Report.psobject.Properties['dynamicUniverse']) { $pairs = @(Get-LegacyUniverseFromLog -Report $Report) $lines = @(Remove-YamlTopLevelBlock -Lines $lines -Name 'pairs') $lines = @(Remove-YamlTopLevelBlock -Lines $lines -Name 'pairlists') $lines = @($lines | ForEach-Object { if ($_ -match '^(?\s*)use_latest\s*:') { "$($Matches.indent)use_latest: false" } else { $_ } }) $quotedPairs = @($pairs | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ', ' $lines += "pairs: [$quotedPairs]" } $lines += @('', 'api_server:', ' enable: false', 'rpc_channels: {}', 'webhook: {}', 'llm_models: {}') $configRoot = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.sanitizedConfigRoot)) New-Item -ItemType Directory -Force -Path $configRoot | Out-Null $destination = Join-Path $configRoot "$($Report.id).yml" Set-Content -LiteralPath $destination -Value $lines -Encoding UTF8 return $destination } function Get-NestedYamlScalar { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Section, [Parameter(Mandatory)][string]$Key) $inside = $false foreach ($line in Get-Content -LiteralPath $Path) { if ($line -match ('^' + [regex]::Escape($Section) + '\s*:')) { $inside = $true; continue } if ($inside -and $line -match '^\S') { break } if ($inside -and $line -match ('^\s+' + [regex]::Escape($Key) + '\s*:\s*(?.*?)\s*$')) { return $Matches.value.Trim('''', '"') } } return '' } function Resolve-DatabaseUrl { if ($DatabaseUrl) { return $DatabaseUrl } if ($DatabaseOverlay) { $overlayFull = [IO.Path]::GetFullPath($DatabaseOverlay) if (-not (Test-Path -LiteralPath $overlayFull -PathType Leaf)) { throw "Database overlay not found: $overlayFull" } $value = Get-NestedYamlScalar -Path $overlayFull -Section 'database' -Key 'url' if ($value) { return $value } } throw 'Database connection is required. Pass a database-only -DatabaseOverlay, -DatabaseUrl, or set BANBOT_DATABASE_URL.' } function Get-DatabaseEndpoint { param([Parameter(Mandatory)][string]$Url) try { $uri = [Uri]$Url if ($uri.Host) { $port = $uri.Port if ($port -le 0) { $port = if ($DatabaseType -eq 'questdb') { 8812 } else { 5432 } } return [pscustomobject]@{ Host = $uri.Host; Port = $port } } } catch { } $hostMatch = [regex]::Match($Url, '(?:^|\s)host=(?[^\s]+)', 'IgnoreCase') $portMatch = [regex]::Match($Url, '(?:^|\s)port=(?\d+)', 'IgnoreCase') if ($hostMatch.Success) { $port = if ($portMatch.Success) { [int]$portMatch.Groups['port'].Value } elseif ($DatabaseType -eq 'questdb') { 8812 } else { 5432 } return [pscustomobject]@{ Host = $hostMatch.Groups['host'].Value; Port = $port } } throw 'Cannot determine database host/port from the supplied URL.' } function Assert-DatabaseAvailable { param([Parameter(Mandatory)][string]$Url) $endpoint = Get-DatabaseEndpoint $Url $client = New-Object Net.Sockets.TcpClient try { $task = $client.ConnectAsync($endpoint.Host, $endpoint.Port) $available = $task.Wait(3000) -and $client.Connected } catch { $available = $false } finally { $client.Dispose() } if ($available) { Write-Host "Database preflight passed at $($endpoint.Host):$($endpoint.Port)" } else { Write-Warning "Database is not currently reachable at $($endpoint.Host):$($endpoint.Port); banbot may auto-start the configured QuestDB service. The backtest exit and artifacts remain the hard check." } return $available } function New-DatabaseRuntimeOverlay { param([Parameter(Mandatory)][string]$Url) if ($DatabaseOverlay) { $overlayFull = [IO.Path]::GetFullPath($DatabaseOverlay) $topKeys = @(Get-Content -LiteralPath $overlayFull | Where-Object { $_ -match '^\S[^#]*:' } | ForEach-Object { ($_ -split ':', 2)[0].Trim() } | Sort-Object -Unique) if ($topKeys.Count -ne 1 -or $topKeys[0] -ne 'database') { throw '-DatabaseOverlay must contain only the top-level database section; full local configs are rejected to prevent credential merging.' } return [pscustomobject]@{ Path = $overlayFull; Temporary = $false } } $quotedUrl = "'" + $Url.Replace("'", "''") + "'" $lines = @('database:', " url: $quotedUrl") if ($DatabaseType) { $lines += " db_type: $DatabaseType" } $runtimePath = Join-Path ([IO.Path]::GetTempPath()) ("banstagy-$([Guid]::NewGuid().ToString('N')).yml") Set-Content -LiteralPath $runtimePath -Value $lines -Encoding UTF8 return [pscustomobject]@{ Path = $runtimePath; Temporary = $true } } function Set-RecentWindow { param([Parameter(Mandatory)][string]$ConfigPath, [Parameter(Mandatory)][string]$Destination) $lines = @(Get-Content -LiteralPath $ConfigPath) foreach ($field in @('timerange', 'time_start', 'time_end')) { $lines = @(Remove-YamlTopLevelBlock -Lines $lines -Name $field) } $today = $script:RecentToday $start = $today.AddYears(-2) $lines += @("time_start: '$($start.ToString('yyyyMMdd'))'", "time_end: '$($today.ToString('yyyyMMdd'))'") Set-Content -LiteralPath $Destination -Value $lines -Encoding UTF8 return [pscustomobject]@{ Start = $start.ToString('yyyyMMdd'); End = $today.ToString('yyyyMMdd') } } function Get-RequiredProperty { param([Parameter(Mandatory)]$Object, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Path) $property = $Object.psobject.Properties[$Name] if ($null -eq $property) { throw "Required detail field missing: $Path.$Name" } return $property.Value } function Get-ComparableGroupTitle { param( [Parameter(Mandatory)]$Report, [Parameter(Mandatory)][string]$GroupName, [Parameter(Mandatory)][string]$Title, [switch]$Expected ) if (-not $Expected -or $GroupName -notin @('enterGrps', 'exitGrps')) { return $Title } foreach ($mapping in $Report.policyMap.psobject.Properties) { $legacy = [string]$mapping.Name if ($Title -eq $legacy) { return [string]$mapping.Value } if ($Title.StartsWith("${legacy}:", [StringComparison]::Ordinal)) { return ([string]$mapping.Value) + $Title.Substring($legacy.Length) } } return $Title } function Compare-Detail { param( [Parameter(Mandatory)]$Report, [Parameter(Mandatory)][string]$ExpectedPath, [Parameter(Mandatory)][string]$ActualPath ) $expected = Get-Content -LiteralPath $ExpectedPath -Raw | ConvertFrom-Json $actual = Get-Content -LiteralPath $ActualPath -Raw | ConvertFrom-Json $diffs = New-Object Collections.Generic.List[object] $universeDiverged = $false foreach ($field in @($script:Manifest.comparison.integerFields)) { $left = Get-RequiredProperty -Object $expected -Name $field -Path '$' $right = Get-RequiredProperty -Object $actual -Name $field -Path '$' if ([decimal]$left -ne [decimal]$right) { $diffs.Add([pscustomobject]@{ path = "`$.$field"; kind = 'integer'; expected = $left; actual = $right; delta = ([decimal]$right - [decimal]$left) }) } } foreach ($field in @($script:Manifest.comparison.floatFields)) { $left = Get-RequiredProperty -Object $expected -Name $field -Path '$' $right = Get-RequiredProperty -Object $actual -Name $field -Path '$' $delta = [Math]::Abs([double]$right - [double]$left) if ($delta -gt $FloatTolerance) { $diffs.Add([pscustomobject]@{ path = "`$.$field"; kind = 'float'; expected = $left; actual = $right; delta = $delta }) } } foreach ($groupName in @($script:Manifest.comparison.groups)) { $leftProperty = $expected.psobject.Properties[$groupName] $rightProperty = $actual.psobject.Properties[$groupName] if ($null -eq $leftProperty -and $null -eq $rightProperty) { continue } if ($null -eq $leftProperty -or $null -eq $rightProperty) { $diffs.Add([pscustomobject]@{ path = "`$.$groupName"; kind = 'schema'; expected = ($null -ne $leftProperty); actual = ($null -ne $rightProperty); delta = $null }) continue } $leftGroup = @($leftProperty.Value) $rightGroup = @($rightProperty.Value) $key = [string]$script:Manifest.comparison.groupKey $leftMap = @{}; $rightMap = @{} foreach ($row in $leftGroup) { $title = [string](Get-RequiredProperty $row $key "`$.$groupName") $title = Get-ComparableGroupTitle -Report $Report -GroupName $groupName -Title $title -Expected $leftMap[$title] = $row } foreach ($row in $rightGroup) { $rightMap[[string](Get-RequiredProperty $row $key "`$.$groupName")] = $row } foreach ($title in @($leftMap.Keys + $rightMap.Keys | Sort-Object -Unique)) { if (-not $leftMap.ContainsKey($title) -or -not $rightMap.ContainsKey($title)) { $diffs.Add([pscustomobject]@{ path = "`$.$groupName[$title]"; kind = 'group'; expected = $leftMap.ContainsKey($title); actual = $rightMap.ContainsKey($title); delta = $null }) if ($groupName -eq 'pairGrps') { $universeDiverged = $true } continue } foreach ($field in @($script:Manifest.comparison.groupIntegerFields)) { $left = Get-RequiredProperty $leftMap[$title] $field "`$.$groupName[$title]" $right = Get-RequiredProperty $rightMap[$title] $field "`$.$groupName[$title]" if ([decimal]$left -ne [decimal]$right) { $diffs.Add([pscustomobject]@{ path = "`$.$groupName[$title].$field"; kind = 'integer'; expected = $left; actual = $right; delta = ([decimal]$right - [decimal]$left) }) } } foreach ($field in @($script:Manifest.comparison.groupFloatFields)) { $left = Get-RequiredProperty $leftMap[$title] $field "`$.$groupName[$title]" $right = Get-RequiredProperty $rightMap[$title] $field "`$.$groupName[$title]" $delta = [Math]::Abs([double]$right - [double]$left) if ($delta -gt $FloatTolerance) { $diffs.Add([pscustomobject]@{ path = "`$.$groupName[$title].$field"; kind = 'float'; expected = $left; actual = $right; delta = $delta }) } } } } return [pscustomobject]@{ matched = ($diffs.Count -eq 0); differenceCount = $diffs.Count; universeDiverged = $universeDiverged; differences = @($diffs | ForEach-Object { $_ }) } } function Get-RedactedLogTail { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return '' } $text = (@(Get-Content -LiteralPath $Path -Tail 250) -join "`n") $text = $text -replace '(?i)(postgres(?:ql)?://[^:/\s]+:)[^@\s]+@', '$1@' $text = $text -replace '(?i)(api[_-]?key|api[_-]?secret|token|password|pwd|secret)\s*[:=]\s*\S+', '$1=' return $text } function Clear-DatabaseUrlInConfig { param([Parameter(Mandatory)][string]$Path) $lines = @(Get-Content -LiteralPath $Path | ForEach-Object { if ($_ -match '^(?\s*)url\s*:') { "$($Matches.indent)url: `"`"" } else { $_ } }) Set-Content -LiteralPath $Path -Value $lines -Encoding UTF8 } function Assert-YamlParseable { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$LogPath) $helper = Join-Path $script:WorkRoot 'yamlcheck.go' if (-not (Test-Path -LiteralPath $helper)) { @' package main import ( "os" "gopkg.in/yaml.v3" ) func main() { data, err := os.ReadFile(os.Args[1]) if err != nil { panic(err) } var value any if err = yaml.Unmarshal(data, &value); err != nil { panic(err) } } '@ | Set-Content -LiteralPath $helper -Encoding UTF8 } $result = Invoke-LoggedCommand -FilePath 'go' -Arguments @('run', $helper, $Path) -LogPath $LogPath -TimeoutSeconds 120 if ($result.ExitCode -ne 0) { throw "Generated config is not parseable YAML; see $LogPath" } } function Assert-BacktestArtifacts { param([Parameter(Mandatory)][string]$OutputDir, [Parameter(Mandatory)][DateTime]$StartedUtc) $paths = @{ detail = Join-Path $OutputDir 'detail.json' config = Join-Path $OutputDir 'config.yml' outLog = Join-Path $OutputDir 'out.log' } foreach ($entry in $paths.GetEnumerator()) { if (-not (Test-Path -LiteralPath $entry.Value -PathType Leaf)) { throw "Backtest did not produce $($entry.Key): $($entry.Value)" } if ((Get-Item -LiteralPath $entry.Value).LastWriteTimeUtc -lt $StartedUtc.AddSeconds(-2)) { throw "Backtest artifact is stale: $($entry.Value)" } } Clear-DatabaseUrlInConfig -Path $paths.config Assert-YamlParseable -Path $paths.config -LogPath (Join-Path $OutputDir 'yaml-parse.log') try { $detail = Get-Content -LiteralPath $paths.detail -Raw | ConvertFrom-Json } catch { throw "detail.json is not parseable: $($paths.detail): $($_.Exception.Message)" } $expectedOut = [IO.Path]::GetFullPath($OutputDir).TrimEnd('\', '/') $actualOut = [IO.Path]::GetFullPath([string](Get-RequiredProperty $detail 'outDir' '$')).TrimEnd('\', '/') if (-not $actualOut.Equals($expectedOut, [StringComparison]::OrdinalIgnoreCase)) { throw "detail.outDir mismatch: expected $expectedOut, got $actualOut" } $numericFields = @($script:Manifest.comparison.integerFields) + @($script:Manifest.comparison.floatFields) foreach ($field in $numericFields) { $value = [double](Get-RequiredProperty $detail $field '$') if ([double]::IsNaN($value) -or [double]::IsInfinity($value)) { throw "Non-finite core metric in detail.json: $field" } } $calcDiff = $null if ($detail.psobject.Properties['calcDiff']) { $calcDiff = [double]$detail.calcDiff if ([Math]::Abs($calcDiff) -gt $FloatTolerance) { Write-Warning "Backtest reports calcDiff=$calcDiff in $OutputDir" } } return [pscustomobject]@{ detail = $paths.detail; config = $paths.config; outLog = $paths.outLog; calcDiff = $calcDiff } } function Invoke-Backtest { param([Parameter(Mandatory)]$Report, [Parameter(Mandatory)][string]$SanitizedConfig, [Parameter(Mandatory)][string]$OutputDir, [Parameter(Mandatory)][string]$Label, [Parameter(Mandatory)][string]$DbUrl, [switch]$RequireOrders, [switch]$NetworkOff) $runRoot = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.runRoot)) Assert-DescendantPath -Root $runRoot -Path $OutputDir | Out-Null New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null $uniqueName = "$Label-$([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'))-$([Guid]::NewGuid().ToString('N').Substring(0, 8))" $OutputDir = Join-Path $OutputDir $uniqueName Assert-DescendantPath -Root $runRoot -Path $OutputDir | Out-Null if (Test-Path -LiteralPath $OutputDir) { throw "Unique output already exists: $OutputDir" } New-Item -ItemType Directory -Path $OutputDir | Out-Null $runtimeOverlay = New-DatabaseRuntimeOverlay -Url $DbUrl $startedUtc = [DateTime]::UtcNow try { $captureLog = Join-Path $OutputDir 'process.log' $args = @('backtest', '-datadir', [string]$script:Manifest.dataDir, '-no-default', '-config', $SanitizedConfig, '-config', $runtimeOverlay.Path, '-out', $OutputDir) if ($NetworkOff) { $args += '-net-off' } $result = Invoke-LoggedCommand -FilePath ([IO.Path]::GetFullPath($BotPath)) -Arguments $args -LogPath $captureLog -TimeoutSeconds ($BacktestTimeoutMinutes * 60) if ($result.ExitCode -ne 0) { throw "Backtest $Label failed for $($Report.id); see $captureLog and $(Join-Path $OutputDir 'out.log')" } $artifacts = Assert-BacktestArtifacts -OutputDir $OutputDir -StartedUtc $startedUtc if ($RequireOrders) { foreach ($name in @('orders.csv', 'orders.gob')) { if (-not (Test-Path -LiteralPath (Join-Path $OutputDir $name) -PathType Leaf)) { throw "Recent backtest did not produce required $name in $OutputDir" } } } return [pscustomobject]@{ output = $OutputDir; detail = $artifacts.detail; botLog = $artifacts.outLog; processLog = $captureLog; startedUtc = $startedUtc.ToString('o'); exitCode = $result.ExitCode; calcDiff = $artifacts.calcDiff } } finally { $emittedConfig = Join-Path $OutputDir 'config.yml' if (Test-Path -LiteralPath $emittedConfig -PathType Leaf) { Clear-DatabaseUrlInConfig -Path $emittedConfig } if ($runtimeOverlay.Temporary -and (Test-Path -LiteralPath $runtimeOverlay.Path)) { Remove-Item -LiteralPath $runtimeOverlay.Path -Force } } } function Invoke-CodexRepair { param([Parameter(Mandatory)]$Report, [Parameter(Mandatory)]$Comparison, [Parameter(Mandatory)][string]$BotLog) $scratch = Join-Path $script:WorkRoot "repair-$($Report.id)" Copy-MigrationInputs -Report $Report -Scratch $scratch $generated = Join-Path (Join-Path $script:RepoRoot $script:Manifest.generatedRoot) $Report.id Copy-Item -LiteralPath $generated -Destination (Join-Path $scratch 'current') -Recurse $diffJson = $Comparison | ConvertTo-Json -Depth 20 $logTail = Get-RedactedLogTail -Path $BotLog $blocks = New-Object Collections.Generic.List[string] $blocks.Add((Get-InlineFileBlock -Label 'banbot_cn.md' -Path (Join-Path $scratch 'input\banbot_cn.md'))) foreach ($relative in @($Report.sourceFiles)) { $blocks.Add((Get-InlineFileBlock -Label "legacy/$($relative.Replace('\', '/'))" -Path (Join-Path (Join-Path $scratch 'input\sources') $relative))) } foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $scratch 'current') -File -Filter '*.go' -Recurse)) { $relative = Get-RelativePath -Base (Join-Path $scratch 'current') -Path $file.FullName $blocks.Add((Get-InlineFileBlock -Label "current/$($relative.Replace('\', '/'))" -Path $file.FullName)) } $inlineInputs = $blocks -join "`n`n" $basePrompt = @" Do not call tools or inspect the filesystem. All allowed inputs are included below. Return only JSON matching the supplied schema, containing the full replacement Go package. Each path must be relative and end in .go. Repair the migrated banbot strategy so its backtest reproduces the legacy metrics. Preserve group $($Report.generatedGroup) and these mappings: $(Get-PolicyInstructions $Report) Diagnose OnData main-vs-info routing, warmup, timeframe, order timing, and state behavior. Make the smallest behavior-preserving fix. Do not weaken comparisons or alter metrics. Do not add dependencies. Do not include markdown fences or explanations. METRIC DIFFERENCES: $diffJson REDACTED LOG TAIL: $logTail $inlineInputs "@ $schemaPath = Join-Path $scratch 'files-schema.json' $replacement = Join-Path $scratch 'replacement' Write-CodexFilesSchema -Path $schemaPath $reasoningConfig = 'model_reasoning_effort="' + $CodexReasoning + '"' $validationFeedback = '' $validated = $false for ($validationAttempt = 0; $validationAttempt -le $MaxCodexValidationAttempts; $validationAttempt++) { $prompt = $basePrompt if ($validationFeedback) { $prompt += @" PREVIOUS RESPONSE VALIDATION ERROR: $validationFeedback Return a corrected full replacement package. Deprecated OnBar and OnInfoBar callbacks must not appear as struct fields. "@ } $attemptStamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff') $logPath = Join-Path $script:LogRoot "codex-repair-$($Report.id)-$attemptStamp-attempt$validationAttempt.log" $responsePath = Join-Path $scratch "files-response-$validationAttempt.json" try { $result = Invoke-LoggedCommand -FilePath 'codex' -Arguments @('exec', '--disable', 'hooks', '--disable', 'multi_agent', '-c', $reasoningConfig, '--sandbox', 'read-only', '--ephemeral', '--skip-git-repo-check', '--output-schema', $schemaPath, '--output-last-message', $responsePath, '-C', $scratch, '-') -LogPath $logPath -InputText $prompt -WorkingDirectory $scratch if ($result.ExitCode -ne 0) { throw "Codex repair command failed; see $logPath" } if (Test-Path -LiteralPath $replacement) { Remove-Item -LiteralPath $replacement -Recurse -Force } Write-CodexResponseFiles -ResponsePath $responsePath -Destination $replacement $formatArgs = @('-w') + @(Get-ChildItem -LiteralPath $replacement -File -Filter '*.go' -Recurse | Select-Object -ExpandProperty FullName) $formatLog = Join-Path $script:LogRoot "gofmt-repair-$($Report.id)-attempt$validationAttempt.log" $format = Invoke-LoggedCommand -FilePath 'gofmt' -Arguments $formatArgs -LogPath $formatLog if ($format.ExitCode -ne 0) { throw "gofmt repair failed; see $formatLog" } Assert-GeneratedPackage -Report $Report -Directory $replacement $validated = $true break } catch { $validationFeedback = $_.Exception.Message if ($validationAttempt -ge $MaxCodexValidationAttempts) { throw "Codex repair validation failed for $($Report.id) after $($validationAttempt + 1) attempts: $validationFeedback" } Write-Warning "Retrying Codex repair for $($Report.id): $validationFeedback" } } if (-not $validated) { throw "Codex repair did not produce a validated package for $($Report.id)" } Assert-DescendantPath -Root (Join-Path $script:RepoRoot $script:Manifest.generatedRoot) -Path $generated | Out-Null Remove-Item -LiteralPath $generated -Recurse -Force Copy-Item -LiteralPath $replacement -Destination $generated -Recurse Invoke-Build } function Read-Summary { if (Test-Path -LiteralPath $script:SummaryPath) { return Get-Content -LiteralPath $script:SummaryPath -Raw | ConvertFrom-Json } return [pscustomobject]@{ schemaVersion = 1 generatedUtc = [DateTime]::UtcNow.ToString('o') floatTolerance = $FloatTolerance recentWindow = $null reports = @() } } function Save-ReportSummary { param([Parameter(Mandatory)]$ReportResult, $RecentWindow = $null) $summary = Read-Summary $summary.generatedUtc = [DateTime]::UtcNow.ToString('o') $summary.floatTolerance = $FloatTolerance if ($null -ne $RecentWindow) { $summary.recentWindow = $RecentWindow } $summary.reports = @(@($summary.reports | Where-Object id -ne $ReportResult.id) + $ReportResult | Sort-Object id) Write-JsonAtomic -Value $summary -Path $script:SummaryPath } function Invoke-Replay { $dbUrl = Resolve-DatabaseUrl $null = Assert-DatabaseAvailable -Url $dbUrl $failures = New-Object Collections.Generic.List[string] foreach ($report in $script:Representatives) { if (-not $Force -and (Test-Completed "replay:$($report.id)")) { Write-Host "Resume: baseline replay already matched for $($report.id)" continue } $sanitized = ConvertTo-SanitizedConfig -Report $report -PinLegacyUniverse $attempts = New-Object Collections.Generic.List[object] $comparison = $null $exactReplayBlocker = $null for ($attempt = 0; $attempt -le $MaxRepairAttempts; $attempt++) { $output = Join-Path (Join-Path (Join-Path $script:RepoRoot $script:Manifest.runRoot) $report.id) 'baseline' $run = Invoke-Backtest -Report $report -SanitizedConfig $sanitized -OutputDir $output -Label "baseline-$attempt" -DbUrl $dbUrl -NetworkOff $comparison = Compare-Detail -Report $report -ExpectedPath (Join-Path (Get-ReportRoot $report) 'detail.json') -ActualPath $run.detail $attempts.Add([pscustomobject]@{ attempt = $attempt; startedUtc = $run.startedUtc; exitCode = $run.exitCode; matched = $comparison.matched; differenceCount = $comparison.differenceCount; calcDiffWarning = $run.calcDiff; output = $run.output; differences = @($comparison.differences) }) if ($comparison.matched) { break } $eventCountDiffs = @($comparison.differences | Where-Object { $_.path -in @('$.barNum', '$.timeNum') }) if ($eventCountDiffs.Count -gt 0) { $exactReplayBlocker = "Archived event counts differ from the current banbot/data environment ($(@($eventCountDiffs | ForEach-Object { $_.path + ': ' + $_.expected + ' -> ' + $_.actual }) -join ', ')); strategy repair is disabled." break } if ($comparison.universeDiverged -and $Report.psobject.Properties['dynamicUniverse']) { $exactReplayBlocker = "The title-keyed pair set diverged for dynamic $($report.dynamicUniverse.source) after pinning the universe from $($report.dynamicUniverse.sourceLog); strategy repair is not appropriate." break } if ($attempt -lt $MaxRepairAttempts) { Invoke-CodexRepair -Report $report -Comparison $comparison -BotLog $run.botLog } } $baselineDetail = Get-Content -LiteralPath (Join-Path (Get-ReportRoot $report) 'detail.json') -Raw | ConvertFrom-Json $universePin = if ($Report.psobject.Properties['dynamicUniverse']) { $Report.dynamicUniverse } else { $null } $result = [pscustomobject]@{ id = $report.id baselineMatched = $comparison.matched baselineWindowOverride = [pscustomobject]@{ startMS = [int64]$baselineDetail.startMS; endMS = [int64]$baselineDetail.endMS } dynamicUniverse = $universePin exactReplayBlocker = $exactReplayBlocker baselineAttempts = @($attempts | ForEach-Object { $_ }) recent = $null } Save-ReportSummary -ReportResult $result if (-not $comparison.matched) { if ($exactReplayBlocker) { Set-Completed "replay:$($report.id)" Set-Completed "replay-blocked:$($report.id)" Write-Warning "Baseline evaluated with an environment blocker for $($report.id): $exactReplayBlocker" continue } $failures.Add($report.id) Write-Warning "Baseline did not match for $($report.id); see $script:SummaryPath" continue } Set-Completed "replay:$($report.id)" Write-Host "Baseline matched: $($report.id)" } if ($failures.Count -gt 0) { throw "Baseline replay incomplete for: $($failures -join ', '). See $script:SummaryPath" } } function Remove-RecentOrderArtifacts { param([Parameter(Mandatory)][string]$OutputDir) $recentRoot = [IO.Path]::GetFullPath((Join-Path $script:RepoRoot $script:Manifest.runRoot)) Assert-NoReparsePath -Root $recentRoot -Path $OutputDir | Out-Null foreach ($name in @('orders.csv', 'orders.gob')) { $path = Join-Path $OutputDir $name Assert-NoReparsePath -Root $OutputDir -Path $path | Out-Null if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Expected recent artifact before cleanup: $path" } Remove-Item -LiteralPath $path -Force if (Test-Path -LiteralPath $path) { throw "Failed to delete recent artifact: $path" } } } function Invoke-Recent { $dbUrl = Resolve-DatabaseUrl $null = Assert-DatabaseAvailable -Url $dbUrl foreach ($report in $script:Representatives) { if (-not (Test-Completed "replay:$($report.id)")) { throw "Recent run requires a matched baseline for $($report.id)" } if (-not $Force -and (Test-Completed "recent:$($report.id)")) { Write-Host "Resume: recent replay already complete for $($report.id)" continue } $sanitized = ConvertTo-SanitizedConfig -Report $report $recentConfig = Join-Path ([IO.Path]::GetTempPath()) ("banstagy-recent-$([Guid]::NewGuid().ToString('N')).yml") try { $window = Set-RecentWindow -ConfigPath $sanitized -Destination $recentConfig $output = Join-Path (Join-Path (Join-Path $script:RepoRoot $script:Manifest.runRoot) $report.id) 'recent' $run = Invoke-Backtest -Report $report -SanitizedConfig $recentConfig -OutputDir $output -Label 'recent' -DbUrl $dbUrl -RequireOrders $recentDetail = Get-Content -LiteralPath $run.detail -Raw | ConvertFrom-Json $startUtc = [DateTime]::SpecifyKind($script:RecentToday.AddYears(-2), [DateTimeKind]::Utc) $endUtc = [DateTime]::SpecifyKind($script:RecentToday, [DateTimeKind]::Utc) $startMS = ([DateTimeOffset]$startUtc).ToUnixTimeMilliseconds() $endMS = ([DateTimeOffset]$endUtc).ToUnixTimeMilliseconds() if ([int64]$recentDetail.startMS -ne $startMS -or [int64]$recentDetail.endMS -ne $endMS) { throw "Recent detail range does not exactly match UTC [$startMS,$endMS): actual $($recentDetail.startMS)-$($recentDetail.endMS)" } Remove-RecentOrderArtifacts -OutputDir $run.output $summary = Read-Summary $existing = @($summary.reports | Where-Object id -eq $report.id | Select-Object -First 1) if ($existing.Count -eq 0) { throw "Missing baseline summary for $($report.id)" } $existing[0].recent = [pscustomobject]@{ output = $run.output; detail = $run.detail; startedUtc = $run.startedUtc; exitCode = $run.exitCode; calcDiffWarning = $run.calcDiff; start = $window.Start; endExclusive = $window.End; ordersRemoved = $true } Save-ReportSummary -ReportResult $existing[0] -RecentWindow ([pscustomobject]@{ start = $window.Start; endExclusive = $window.End; timezone = 'UTC' }) Set-Completed "recent:$($report.id)" Write-Host "Recent two-year replay complete: $($report.id)" } finally { if (Test-Path -LiteralPath $recentConfig) { Remove-Item -LiteralPath $recentConfig -Force } } } } New-Item -ItemType Directory -Force -Path $script:WorkRoot, $script:LogRoot | Out-Null $script:Representatives = @(Resolve-Representatives) foreach ($report in $script:Representatives) { Assert-RepresentativeInputs $report } switch ($Phase) { 'Inventory' { Invoke-Inventory } 'Migrate' { Invoke-Migrate } 'Integrate' { Invoke-Integrate } 'Build' { Invoke-Build } 'Replay' { Invoke-Replay } 'Recent' { Invoke-Recent } 'All' { Invoke-Inventory Invoke-Migrate Invoke-Integrate Invoke-Build Invoke-Replay Invoke-Recent } }