<?php
/**
* PHP 版 CLI 入口。
*
* 用法:
* php cli.php --risk 稳健 --horizon 波段 [--style momentum=0.4,low_vol=0.3,trend=0.3]
* [--positions 12] [--date 2026-10-28] [--code-file ../quantlab/data/universe_sample.csv]
*
* 输出:策略规格 + 最新截面选股信号(JSON)。数据直接复用 Python 侧缓存的
* quantlab/data/kline/*.csv(也可由 FactorEngine::fetchKline 自行抓取腾讯接口)。
*/
require __DIR__ . '/src/StrategyGenerator.php';
require __DIR__ . '/src/FactorEngine.php';
$options = getopt('', ['risk:', 'horizon:', 'style::', 'positions::', 'rebalance::', 'cash::', 'date::', 'code-file::', 'data-dir::', 'no-trend-filter']);
$risk = isset($options['risk']) ? $options['risk'] : '平衡';
$horizon = isset($options['horizon']) ? $options['horizon'] : '波段';
$style = null;
if (!empty($options['style'])) {
$style = [];
foreach (explode(',', $options['style']) as $kv) {
$parts = explode('=', $kv);
if (count($parts) === 2) {
$style[trim($parts[0])] = (float)$parts[1];
}
}
}
try {
$cfg = StrategyGenerator::generate($risk, $horizon, $style, [
'max_positions' => isset($options['positions']) ? (int)$options['positions'] : null,
'rebalance_days' => isset($options['rebalance']) ? (int)$options['rebalance'] : null,
'cash_buffer' => isset($options['cash']) ? (float)$options['cash'] : null,
'trend_filter' => !isset($options['no-trend-filter']),
'name' => "PHP-{$risk}{$horizon}多因子",
]);
$codeFile = isset($options['code-file'])
? $options['code-file']
: realpath(__DIR__ . '/../quantlab/data/universe_sample.csv');
$dataDir = isset($options['data-dir'])
? $options['data-dir']
: realpath(__DIR__ . '/../quantlab/data/kline');
$codes = [];
if ($codeFile && is_file($codeFile)) {
foreach (file($codeFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line, "\xEF\xBB\xBF");
if ($line === '' || strpos($line, '#') === 0 || strpos($line, 'code,') === 0) {
continue;
}
$codes[] = explode(',', $line)[0];
}
}
if (!$codes) {
throw new RuntimeException('未找到股票池文件: ' . var_export($codeFile, true));
}
// 目标日期:默认取全池最新共同交易日
$asOf = isset($options['date']) ? $options['date'] : null;
$universe = [];
$loaded = 0;
foreach ($codes as $code) {
$kline = FactorEngine::loadCsv($dataDir . '/' . $code . '.csv');
if (!$kline) {
$kline = FactorEngine::fetchKline($code); // 缓存缺失时自行抓取
}
if (count($kline) >= 30) {
$universe[$code] = $kline;
$loaded++;
}
}
if (!$asOf) {
$latest = '';
foreach ($universe as $kline) {
$keys = array_keys($kline);
$last = $keys[count($keys) - 1];
if ($last > $latest) {
$latest = $last;
}
}
$asOf = $latest;
}
$engine = new FactorEngine($cfg);
$t0 = microtime(true);
$result = $engine->selectAt($asOf, $universe);
$elapsed = round((microtime(true) - $t0) * 1000);
// 读取名称
$names = [];
if ($codeFile && is_file($codeFile)) {
foreach (file($codeFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line, "\xEF\xBB\xBF");
if ($line === '' || strpos($line, '#') === 0 || strpos($line, 'code,') === 0) {
continue;
}
$p = explode(',', $line, 2);
if (count($p) === 2) {
$names[$p[0]] = $p[1];
}
}
}
$holdings = [];
foreach ($result['holdings'] as $code => $w) {
$holdings[] = ['code' => (string)$code, 'name' => isset($names[$code]) ? $names[$code] : '', 'weight' => round($w, 4)];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'as_of' => $asOf,
'universe_size' => count($universe),
'eligible' => $result['eligible'],
'config' => $cfg,
'holdings' => $holdings,
'scores' => array_map(function ($c, $v) {
return ['code' => (string)$c, 'score' => round($v, 3)];
}, array_keys($result['scores']), array_values($result['scores'])),
'engine' => 'php',
'elapsed_ms' => $elapsed,
'disclaimer' => '输出为量化信号,仅供研究参考,不构成投资建议。市场有风险,投资需谨慎。',
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
exit(1);
}