<?php
/**
 * 因子引擎 + 组合信号（PHP 版）。
 *
 * 输入：quantlab/data/kline/{code}.csv（Python 侧缓存的日线前复权数据，
 *      列：date,open,high,low,close,volume），也可用 self::fetchKline 直接抓腾讯接口。
 * 输出：截面打分、TopN 目标持仓。
 *
 * PHP 7.3+ 兼容，无外部依赖。
 */

class FactorEngine
{
    /** @var array 策略规格（StrategyGenerator::generate 的输出） */
    private $cfg;

    public function __construct(array $cfg)
    {
        $this->cfg = $cfg;
    }

    /** 读取单标的日线 CSV -> [date => [open,high,low,close,volume]]（升序）。 */
    public static function loadCsv($path)
    {
        $rows = [];
        if (!is_file($path)) {
            return $rows;
        }
        $fh = fopen($path, 'r');
        if (!$fh) {
            return $rows;
        }
        $header = fgetcsv($fh); // date,open,high,low,close,volume
        while (($line = fgetcsv($fh)) !== false) {
            if (count($line) < 6 || $line[0] === 'date') {
                continue;
            }
            $rows[$line[0]] = [
                'open' => (float)$line[1], 'high' => (float)$line[2],
                'low' => (float)$line[3], 'close' => (float)$line[4],
                'volume' => (float)$line[5],
            ];
        }
        fclose($fh);
        ksort($rows);
        return $rows;
    }

    /** 直接抓腾讯日线（qfq），带本地 CSV 缓存。 */
    public static function fetchKline($code, $startDate = '2021-06-01', $cacheDir = null)
    {
        $market = (strpos('69', $code[0]) !== false || substr($code, 0, 3) === '688') ? 'sh' : 'sz';
        $sym = $market . $code;
        $cacheDir = $cacheDir ?: (__DIR__ . '/../data/kline');
        if (!is_dir($cacheDir)) {
            @mkdir($cacheDir, 0777, true);
        }
        $cacheFile = $cacheDir . '/' . $code . '.csv';
        $today = date('Y-m-d');
        if (is_file($cacheFile)) {
            $fh = fopen($cacheFile, 'r');
            $last = '';
            while (($line = fgetcsv($fh)) !== false) {
                if (isset($line[0]) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $line[0])) {
                    $last = $line[0];
                }
            }
            fclose($fh);
            if ($last >= date('Y-m-d', strtotime('-4 days'))) {
                return self::loadCsv($cacheFile);
            }
        }
        $url = 'https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param='
            . rawurlencode("{$sym},day,{$startDate},{$today},2000,qfq");
        $ctx = stream_context_create(['http' => ['header' => "User-Agent: Mozilla/5.0\r\n", 'timeout' => 15]]);
        $raw = @file_get_contents($url, false, $ctx);
        if (!$raw) {
            return is_file($cacheFile) ? self::loadCsv($cacheFile) : [];
        }
        $json = json_decode($raw, true);
        $rows = isset($json['data'][$sym]['qfqday']) ? $json['data'][$sym]['qfqday']
            : (isset($json['data'][$sym]['day']) ? $json['data'][$sym]['day'] : []);
        if (!$rows) {
            return is_file($cacheFile) ? self::loadCsv($cacheFile) : [];
        }
        $out = "date,open,high,low,close,volume\n";
        foreach ($rows as $r) {
            $out .= implode(',', [$r[0], $r[1], $r[3], $r[4], $r[2], (float)$r[5] * 100]) . "\n";
        }
        file_put_contents($cacheFile, $out);
        return self::loadCsv($cacheFile);
    }

    /**
     * 单标的全量因子序列（每项 [date => value]）。所有因子只用当日及之前数据。
     * @return array factorName => [date => value]
     */
    public function computeFactors(array $kline)
    {
        $dates = array_keys($kline);
        $n = count($dates);
        if ($n < 30) {
            return [];
        }
        $sel = $this->cfg['selection'];
        $lb = $sel['factor_lookbacks'];

        // 预取数组
        $close = [];
        $ret = [];
        $prev = null;
        foreach ($dates as $d) {
            $c = $kline[$d]['close'];
            $close[$d] = $c;
            $ret[$d] = ($prev !== null && $prev > 0) ? $c / $prev - 1.0 : null;
            $prev = $c;
        }

        $out = [];
        $factors = $sel['factors'];

        if (isset($factors['momentum']) && $factors['momentum'] > 0) {
            $s = [];
            foreach ($dates as $i => $d) {
                if ($i >= $lb['momentum'] + 5) {
                    $s[$d] = $close[$dates[$i - 5]] / $close[$dates[$i - $lb['momentum']]] - 1.0;
                }
            }
            $out['momentum'] = $s;
        }
        if (isset($factors['quality_momentum']) && $factors['quality_momentum'] > 0) {
            $s = [];
            $w = $lb['quality_momentum'];
            $sum = 0.0;
            $sumSq = 0.0;
            $cnt = 0;
            $queue = [];
            foreach ($dates as $i => $d) {
                $r = $ret[$d];
                if ($r !== null) {
                    $queue[] = $r;
                    $sum += $r;
                    $sumSq += $r * $r;
                    $cnt++;
                    if ($cnt > $w) {
                        $old = array_shift($queue);
                        $sum -= $old;
                        $sumSq -= $old * $old;
                        $cnt--;
                    }
                }
                if ($cnt >= 30) {
                    $mean = $sum / $cnt;
                    $var = max(0.0, $sumSq / $cnt - $mean * $mean);
                    $sd = sqrt($var);
                    $s[$d] = $sd > 1e-12 ? ($mean / $sd) * sqrt(252) : null;
                }
            }
            $out['quality_momentum'] = $s;
        }
        if (isset($factors['reversal']) && $factors['reversal'] > 0) {
            $s = [];
            foreach ($dates as $i => $d) {
                if ($i >= $lb['reversal']) {
                    $s[$d] = -($close[$d] / $close[$dates[$i - $lb['reversal']]] - 1.0);
                }
            }
            $out['reversal'] = $s;
        }
        if (isset($factors['low_vol']) && $factors['low_vol'] > 0) {
            $s = [];
            $w = $lb['low_vol'];
            $queue = [];
            $sum = 0.0;
            $sumSq = 0.0;
            $cnt = 0;
            foreach ($dates as $d) {
                $r = $ret[$d];
                if ($r !== null) {
                    $queue[] = $r;
                    $sum += $r;
                    $sumSq += $r * $r;
                    $cnt++;
                    if ($cnt > $w) {
                        $old = array_shift($queue);
                        $sum -= $old;
                        $sumSq -= $old * $old;
                        $cnt--;
                    }
                }
                if ($cnt >= max(20, (int)($w / 3))) {
                    $mean = $sum / $cnt;
                    $var = max(0.0, $sumSq / $cnt - $mean * $mean);
                    $s[$d] = $var > 1e-18 ? -sqrt($var) : null;
                }
            }
            $out['low_vol'] = $s;
        }
        if (isset($factors['trend']) && $factors['trend'] > 0) {
            $s = [];
            $w = $lb['trend'];
            $rolling = [];
            $sum = 0.0;
            foreach ($dates as $i => $d) {
                $rolling[] = $close[$d];
                $sum += $close[$d];
                if (count($rolling) > $w) {
                    $sum -= array_shift($rolling);
                }
                if (count($rolling) >= max(20, (int)($w / 3))) {
                    $s[$d] = $close[$d] / ($sum / count($rolling)) - 1.0;
                }
            }
            $out['trend'] = $s;
        }
        if (isset($factors['high_52w']) && $factors['high_52w'] > 0) {
            $s = [];
            $w = $lb['high_52w'];
            foreach ($dates as $i => $d) {
                if ($i >= 120) {
                    $from = max(0, $i - $w + 1);
                    $hi = 0.0;
                    for ($j = $from; $j <= $i; $j++) {
                        if ($close[$dates[$j]] > $hi) {
                            $hi = $close[$dates[$j]];
                        }
                    }
                    $s[$d] = $hi > 0 ? $close[$d] / $hi : null;
                }
            }
            $out['high_52w'] = $s;
        }
        if (isset($factors['liquidity']) && $factors['liquidity'] > 0) {
            $s = [];
            $w = $lb['liquidity'];
            $rolling = [];
            $sum = 0.0;
            foreach ($dates as $d) {
                $amt = $kline[$d]['close'] * $kline[$d]['volume'];
                $rolling[] = $amt;
                $sum += $amt;
                if (count($rolling) > $w) {
                    $sum -= array_shift($rolling);
                }
                if (count($rolling) >= $w) {
                    $avg = $sum / $w;
                    $s[$d] = $avg > 1.0 ? log($avg) : null;
                }
            }
            $out['liquidity'] = $s;
        }
        return $out;
    }

    /**
     * 截面选股：在指定日期对全部标的打分并选出目标持仓。
     *
     * @param string $date     Y-m-d
     * @param array  $universe code => kline 数组
     * @return array ['holdings'=>[code=>weight], 'scores'=>[code=>score], 'excluded'=>int]
     */
    public function selectAt($date, array $universe)
    {
        $sel = $this->cfg['selection'];
        $rc = $this->cfg['risk_control'];
        $scores = [];
        $minHistory = $sel['min_history'];
        $eligibleCount = 0;

        // 第一遍：收集各因子截面值
        $cross = []; // factor => code => value
        foreach ($universe as $code => $kline) {
            $dates = array_keys($kline);
            if (count($dates) < $minHistory) {
                continue;
            }
            $idx = array_search($date, $dates, true);
            if ($idx === false || $idx < $minHistory - 1) {
                // 用 <= date 的最近一天
                $cand = null;
                foreach ($dates as $d) {
                    if ($d <= $date) {
                        $cand = $d;
                    } else {
                        break;
                    }
                }
                if ($cand === null) {
                    continue;
                }
                $dateEff = $cand;
                $idx = array_search($dateEff, $dates, true);
            } else {
                $dateEff = $date;
            }
            $eligibleCount++;
            if ($rc['trend_filter']) {
                $maWin = $rc['trend_filter_ma'];
                $from = max(0, $idx - $maWin + 1);
                $sum = 0.0;
                $cnt = 0;
                for ($j = $from; $j <= $idx; $j++) {
                    $sum += $kline[$dates[$j]]['close'];
                    $cnt++;
                }
                $ma = $cnt > 0 ? $sum / $cnt : 0.0;
                if ($cnt < (int)($maWin / 2) || $kline[$dateEff]['close'] < $ma) {
                    continue; // 趋势过滤：不入选
                }
            }
            $factors = $this->computeFactors($kline);
            foreach ($factors as $fname => $series) {
                if (isset($series[$dateEff]) && $series[$dateEff] !== null) {
                    $cross[$fname][$code] = $series[$dateEff];
                }
            }
        }

        // 第二遍：逐因子截面去极值+Z-score，加权合成
        $composite = [];
        foreach ($cross as $fname => $vals) {
            $w = isset($sel['factors'][$fname]) ? $sel['factors'][$fname] : 0.0;
            if ($w <= 0) {
                continue;
            }
            $z = self::crossSectionZ($vals);
            foreach ($z as $code => $v) {
                $composite[$code] = (isset($composite[$code]) ? $composite[$code] : 0.0) + $w * $v;
            }
        }
        arsort($composite);
        $top = array_slice($composite, 0, $sel['max_positions'], true);

        $port = $this->cfg['portfolio'];
        $base = 1.0 - $port['cash_buffer'];
        $n = count($top);
        $holdings = [];
        if ($n > 0) {
            $wEach = min($port['max_weight'], $base / $n);
            foreach ($top as $code => $sc) {
                $holdings[$code] = $wEach;
            }
        }
        return ['holdings' => $holdings, 'scores' => $top, 'eligible' => count($composite)];
    }

    /** 截面去极值（MAD 3 倍）+ Z-score。 */
    public static function crossSectionZ(array $vals)
    {
        $n = count($vals);
        if ($n < 2) {
            return array_map(function () {
                return 0.0;
            }, $vals);
        }
        $sorted = array_values($vals);
        sort($sorted);
        $median = $n % 2 ? $sorted[($n - 1) / 2] : ($sorted[$n / 2 - 1] + $sorted[$n / 2]) / 2;
        $devs = [];
        foreach ($vals as $v) {
            $devs[] = abs($v - $median);
        }
        sort($devs);
        $mad = $n % 2 ? $devs[($n - 1) / 2] : ($devs[$n / 2 - 1] + $devs[$n / 2]) / 2;
        $scale = 1.4826 * $mad;
        $clipped = [];
        if ($scale < 1e-12) {
            $scale = 0.0;
        }
        foreach ($vals as $k => $v) {
            if ($scale > 0) {
                $clipped[$k] = max($median - 3 * $scale, min($median + 3 * $scale, $v));
            } else {
                $clipped[$k] = $v;
            }
        }
        $mean = array_sum($clipped) / $n;
        $var = 0.0;
        foreach ($clipped as $v) {
            $var += ($v - $mean) * ($v - $mean);
        }
        $sd = sqrt($var / ($n - 1));
        $out = [];
        foreach ($clipped as $k => $v) {
            $out[$k] = $sd > 1e-12 ? ($v - $mean) / $sd : 0.0;
        }
        return $out;
    }
}
