ek-ctp 0.3.0

CTP gateway for the enkeep ek ecosystem: MD/TD session drivers mapping CTP wire rows onto ek-data objects over the ek-event bus (pure-logic core; the CTP SDK ships inside this package and native bridging lands behind the optional `native` feature).
Documentation
<#
  SimNow front-end selector -- picks which front set to use, based on Beijing time,
  optionally probing TCP reachability first.

  Rule (same as README "local test config"):
    - Trading hours (Beijing, Mon-Fri):
        day  08:55-15:15, night 20:55-02:35
      -> set 1 "same as live trading" (preferred whenever both work)
    - Otherwise -> set 2 "7x24"

  Usage (works on Windows PowerShell 5.1 and PowerShell 7):
    powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1
    powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Probe
    powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Env
    powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Force 724

  NOTE: this file is intentionally ASCII-only. Windows PowerShell 5.1 decodes
  .ps1 files as ANSI unless they carry a UTF-8 BOM, which corrupts non-ASCII
  string literals and breaks the parser. Keep it ASCII.

  SimNow is closed on weekends/holidays; the 7x24 set is also unavailable
  between Saturday 12:00 and Sunday 16:00 (the script warns about that).
#>
[CmdletBinding()]
param(
    [string]$Config = '',
    [ValidateSet('live', '724')][string]$Force,
    [switch]$Probe,
    [switch]$Env
)

$ErrorActionPreference = 'Stop'

# $PSScriptRoot is EMPTY while param() defaults are being bound when this script is
# started with -File from another PowerShell host -> the documented invocation used to
# die on "Join-Path: Cannot bind argument to parameter 'Path'". Resolve it here instead.
if ([string]::IsNullOrEmpty($Config)) {
    $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
    $Config = Join-Path $scriptDir '..\config\simnow.local.env'
}

# ---------- read KEY=VALUE config (force UTF-8 for PS 5.1) ----------
if (-not (Test-Path -LiteralPath $Config)) {
    Write-Error "config file not found: $Config (start from config/simnow.example.env)"
    exit 2
}
$kv = @{}
foreach ($line in (Get-Content -LiteralPath $Config -Encoding UTF8)) {
    $t = $line.Trim()
    if ($t -eq '' -or $t.StartsWith('#')) { continue }
    $i = $t.IndexOf('=')
    if ($i -lt 1) { continue }
    $kv[$t.Substring(0, $i).Trim()] = $t.Substring($i + 1).Trim()
}
function Get-Cfg([string]$k, [string]$d = '') {
    if ($kv.ContainsKey($k) -and $kv[$k] -ne '') { return $kv[$k] }
    return $d
}

# ---------- Beijing time (fixed +08:00, matches ek-ctp D-TZ) ----------
$bj = [datetime]::UtcNow.AddHours(8)

# Trading window: day 08:55-15:15, night 20:55-02:35, Mon-Fri
function Test-TradingWindow([datetime]$t) {
    if ($t.DayOfWeek -eq [DayOfWeek]::Saturday -or $t.DayOfWeek -eq [DayOfWeek]::Sunday) { return $false }
    $hm = $t.Hour * 60 + $t.Minute
    return (($hm -ge 535 -and $hm -le 915) -or ($hm -ge 1255 -or $hm -le 155))
}

# TCP reachability probe (tcp://host:port, 1.5s timeout)
function Test-Front([string]$addr) {
    if ($addr -notmatch '^tcp://(?<h>[^:]+):(?<p>\d+)$') { return $false }
    $h = $Matches['h']
    $p = [int]$Matches['p']
    $c = New-Object System.Net.Sockets.TcpClient
    try {
        $iar = $c.BeginConnect($h, $p, $null, $null)
        if (-not $iar.AsyncWaitHandle.WaitOne(1500)) { return $false }
        $c.EndConnect($iar)
        return $true
    } catch {
        return $false
    } finally {
        $c.Close()
    }
}

# ---------- config values ----------
$liveTd = Get-Cfg 'EK_CTP_LIVE_TD'
$liveMd = Get-Cfg 'EK_CTP_LIVE_MD'
$bakTd = Get-Cfg 'EK_CTP_LIVE_TD_BAK'
$bakMd = Get-Cfg 'EK_CTP_LIVE_MD_BAK'
$p724Td = Get-Cfg 'EK_CTP_724_TD'
$p724Md = Get-Cfg 'EK_CTP_724_MD'

$inWindow = Test-TradingWindow $bj
$probeLines = @()

# ---------- decide ----------
$pick = ''
$reason = ''
if ($Force -ne '') {
    $pick = $Force
    $reason = "forced by -Force $Force"
} elseif ($Probe) {
    $liveTdOk = Test-Front $liveTd
    $liveMdOk = Test-Front $liveMd
    $bakTdOk = $false
    if ($bakTd -ne '') { $bakTdOk = Test-Front $bakTd }
    $bakMdOk = $false
    if ($bakMd -ne '') { $bakMdOk = Test-Front $bakMd }
    $p724TdOk = Test-Front $p724Td
    $p724MdOk = Test-Front $p724Md

    $probeLines += "probe set1 trade $liveTd -> $(if ($liveTdOk) { 'UP' } else { 'down' })"
    $probeLines += "probe set1 md    $liveMd -> $(if ($liveMdOk) { 'UP' } else { 'down' })"
    if ($bakTd -ne '') { $probeLines += "probe set1 trade(bak) $bakTd -> $(if ($bakTdOk) { 'UP' } else { 'down' })" }
    if ($bakMd -ne '') { $probeLines += "probe set1 md(bak)    $bakMd -> $(if ($bakMdOk) { 'UP' } else { 'down' })" }
    $probeLines += "probe set2 trade $p724Td -> $(if ($p724TdOk) { 'UP' } else { 'down' })"
    $probeLines += "probe set2 md    $p724Md -> $(if ($p724MdOk) { 'UP' } else { 'down' })"

    $liveOk = ($liveTdOk -or $bakTdOk)
    $p724Ok = $p724TdOk
    if ($liveOk) {
        $pick = 'live'
        $reason = 'probe: set1 (live-aligned) reachable, preferred'
    } elseif ($p724Ok) {
        $pick = '724'
        $reason = 'probe: set1 unreachable, set2 (7x24) reachable'
    } else {
        if ($inWindow) { $pick = 'live' } else { $pick = '724' }
        $reason = 'probe: neither reachable (outside service window?), fell back to time rule'
    }
} elseif ($inWindow) {
    $pick = 'live'
    $reason = 'inside trading window, prefer set1 (live-aligned)'
} else {
    $pick = '724'
    $reason = 'outside trading window, use set2 (7x24)'
}

if ($pick -eq 'live') {
    $td = $liveTd
    $md = $liveMd
    $tdBak = $bakTd
    $mdBak = $bakMd
    $setName = 'set1 (live-aligned)'
} else {
    $td = $p724Td
    $md = $p724Md
    $tdBak = ''
    $mdBak = ''
    $setName = 'set2 (7x24)'
}

if ($Env) {
    "EK_CTP_TD_ADDR=$td"
    "EK_CTP_MD_ADDR=$md"
    exit 0
}

# ---------- report ----------
"beijing time  : $($bj.ToString('yyyy-MM-dd HH:mm:ss')) (day-of-week $([int]$bj.DayOfWeek))"
"trading hours : $(if ($inWindow) { 'yes' } else { 'no' })"
"selected      : $setName"
"reason        : $reason"
"td front      : $td"
"md front      : $md"
if ($tdBak -ne '') { "td front (bak): $tdBak" }
if ($mdBak -ne '') { "md front (bak): $mdBak" }
"flow dir      : $(Get-Cfg 'EK_CTP_FLOW_DIR' 'target/ctp-flow')"
foreach ($l in $probeLines) { $l }

# 7x24 set service window: trading day 16:00-next 09:00 / non-trading day 16:00-next 12:00
if ($pick -eq '724') {
    $hm = $bj.Hour * 60 + $bj.Minute
    $weekendGap = ($bj.DayOfWeek -eq [DayOfWeek]::Saturday -and $hm -gt 720) -or ($bj.DayOfWeek -eq [DayOfWeek]::Sunday -and $hm -lt 960)
    if ($weekendGap) {
        "warning       : between Sat 12:00 and Sun 16:00, SimNow is normally closed"
    }
}
"note          : SimNow provides snapshot quotes only (no tick-by-tick); closed on weekends/holidays"