<?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;
}
}