← 返回 下载 cli.php
<?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);
}