止观
AI前沿观察

[F8]每晚22:10,我的服务器自己给自己写投资日报

收盘后的那天晚上十点十分,机器自己开始动笔写当天的复盘。我给它提过最高要求不是算得快,是它不许糊弄我。写完这篇你会明白:这类系统最难的部分从来不是策略本身,是让自己不骗自己。

先交代背景。假设你有一台常年开着的机器,白天它什么都不干,晚上你把当天的行情数据汇总进去,它输出一份日报:市场环境处在什么状态、候选池里哪些标的分数靠前、模拟账户今天的盈亏和违规检查。我自己的这套跑了大半年,改来改去,最后发现值得写的不是它替我挑了什么票,而是它怎么防着我。

这篇是 AI 项目实战第二篇。上一篇聊的是给孩子做的背诗 App,评论区有人问“你有没有做股票相关的自动化”,这篇就是回答。只讲架构分层和方法学,账号、IP、真实参数一概不出现;文中所有代码都在本机示意数据上实跑过,输出是原样贴的;所有阈值都是编的,跟任何真实系统无关。

先给你导读,三句话能读完的部分我放前面,赶时间的读者看完这张表就可以走:

层它回答的问题它最容易烂的方式
闸门层现在该买多少在临界点反复横跳,把仓位甩来甩去
评分层在允许买的前提下买什么权重拍脑袋定,再也不敢回头看
回测层这套东西到底有没有用未来函数偷答案,回测好看得吓人

※ ※ ※

※ ※ ※

🌙 它每晚十点十分到底在干什么

整套流水我定在交易日晚上 22:10 触发,错开结算和数据推送的时间窗。把它拆开,其实就是四步:取数、判环境、打分、成稿投递。

取数这步没什么戏剧性。白天收盘后数据源把当天的日线、榜单、估值这些公开数据推到本地,晚上日报任务醒来,先检查数据新不新鲜。这里埋着第一个坑:数据源说“我有 T+1 披露延迟”的字段,你按 T+0 用,就是拿明天才公布的信息做今天的决定,严格说这已经是未来函数了,只是穿了一身“我用的是官方 API”的皮。

判环境是闸门层的事,下一节细说。打分是评分层,再下一节。成稿这步我一开始特别在意文笔,后来彻底放弃:日报正文全部是模板拼数字,一句形容词都不让大模型润色。原因很实在,语言模型给浮亏 8% 的持仓配上“仍需耐心等待估值修复”这种句子,看着是安慰,实际是在替亏损找叙事。投资记录里最不该出现的文风,就是给没发生的事编理由。

投递端还有个细节值得说:我的口径是“没有变化就不出声”。市场状态没翻转、持仓没触发条件、候选池没进新票,任务就一个字都不推。这个规矩被上篇定时任务那篇文章剧透过一次技术原因(空输出和报错输出的区别),这里想强调的是纪律意义:一个每晚都给你发“一切正常”的系统,三个月后你连“不正常”的消息都不会点开。

这是对做量化入门的人最有用的第一段:日报系统不是荐股机器,它是一台约束执行机。你白天盯盘时定的规则,晚上由一台没有情绪的机器替你核对。

夜间流水与回环审计

一句话:取数、判环境、打分、成稿,四步里真正难的是第二步和第四步,因为那两步全是人性。

※ ※ ※

※ ※ ※

🚦 市场闸门:它管的不是买什么,是买多少

闸门层的设计目标一句话能说清:市场环境负责“买多少”,个股条件负责“买不买”,两件事必须解耦。我见过太多新手把这两层搅在一起,用“大盘不好所以这只票也不买”做决策,结果市场回暖那天,他手里的候选票早就涨上去了,个股条件其实三天前就该触发。

最朴素的闸门是一个阈值:给市场打一个环境分,分数高就开闸,分数低就关闸。我第一版就是这么写的,然后被现实教育了。任何阈值型指标,只要行情在它附近震荡,就会翻转。分数在阈值线上下一毫米一毫米地磨,你的仓位就跟着满进满出。手续费是小事,真正难受的是行为本身:一个天天被行情打耳光的系统,你不可能长期信任它,最后你会绕过它,手动来。绕过硬护栏的系统等于没有硬护栏。

解法是控制论里玩了几十年的东西:迟滞(hysteresis)。别用一条线,用两条。往下跌破“下轨”才拉闸,但要回到“退出线”以上才复闸,两条线之间的灰区什么都不做,保持原状态。翻一下你家的空调温控器,原理一模一样,设定 26 度它不会在 26.0 度反复启停,靠的就是迟滞带。

再叠一层防抖:状态翻转不看单日,要求连续若干天满足才认。单日暴跌可以触发拉闸评估,但要连续几天都在闸外才真关;恢复更慢,市场反抽一天就重开闸,是最常见的送钱方式。我现在的实现里,开闸比关闸快、关闸比开闸狠,方向是反的,宁可钝,不要跳。

下面是我在本机跑的示意版闸门,合成一条“市场广度”日线序列(当日创阶段新高的家数占比),做 60 日 z 分,再套迟滞状态机。所有阈值都是示例值,跟真实系统没有任何对应关系:

# 市场闸门:广度 z 分 + 迟滞状态机(示例数据,非实盘参数)
import pandas as pd
import numpy as np

rng = np.random.default_rng(42)
n = 300
# 合成一条"市场广度"日线序列:当日创20日新高家数占比
breadth = np.clip(rng.normal(0.5, 0.12, n) + 0.15*np.sin(np.arange(n)/28), 0.02, 0.98)
px = pd.DataFrame({"date": pd.date_range("2025-01-01", periods=n, freq="B"), "breadth": breadth})
px["z"] = (px["breadth"] - px["breadth"].rolling(60).mean()) / px["breadth"].rolling(60).std()

# 迟滞:进警示易、出警示难。上轨/下轨都是示例口径,写死在配置里,不跟着行情改
UP, LOW, EXIT = 1.0, -1.0, -0.4   # 示意阈值,非任何实盘参数
state, out = "normal", []
for z in px["z"]:
    if np.isnan(z):
        out.append("warmup"); continue
    if state == "normal" and z < LOW:  state = "alert"      # 跌破下轨才拉闸
    elif state == "alert" and z > EXIT: state = "normal"    # 回到退出线以上才复闸
    out.append(state)
px["gate"] = out
print(px.tail(8).to_string(index=False, float_format=lambda v: f"{v:.3f}"))
print("\n闸门状态分布:", px["gate"].value_counts().to_dict())

实跑输出(末 8 行):

date  breadth      z   gate
2026-02-13    0.550  0.333 normal
2026-02-16    0.063 -2.973  alert
2026-02-17    0.340 -1.024  alert
2026-02-18    0.387 -0.680  alert
2026-02-19    0.399 -0.603  alert
2026-02-20    0.317 -1.129  alert
2026-02-23    0.148 -2.147  alert
2026-02-24    0.397 -0.502  alert

闸门状态分布: {'normal': 148, 'alert': 93, 'warmup': 59}

注意倒数第一行:2 月 24 日 z 是 -0.502,已经回到下轨(-1.0)上方不少,但闸门状态依然是 alert,因为它还没爬过 -0.4 的退出线。这一天如果按朴素阈值早就“金叉复活”了,迟滞让它再忍一天。灰区里的每一分犹豫,都是省下来的手续费。

闸门迟滞状态机

还有个反直觉的点,是环境分档位设计逼我想明白的:市场弱不等于清仓。闸门关的是“新开仓”的权限,卖出永远自由,止损任何时候不许被任何状态覆盖。这条是我在模拟盘里交过一次学费后焊死的:单边下跌里“环境分低所以清仓”和“环境分低所以禁买”是两个世界,前者把浮亏变实亏,后者只是不添新伤。仓位档位也不是线性越跌越买,我最后用的是非线性的形状,健康震荡带给高仓、拥挤追高带主动降仓,具体数值不给它上户口,反正都是示意。

如果你是把钱交给规则的人,这节对你最有用:闸门的价值不在预测,在把“手痒”隔离在灰区之外。

一句话:一条阈值线是给行情当鞭子,两条线加迟滞才是给自己当刹车。

※ ※ ※

※ ※ ※

📊 因子评分:权重不是神谕,是一份可以被审计的立场

闸门放行之后才轮到选股。评分层的规矩同样一句话:市场环境管“买多少”,个股因子管“买不买”,因子分不许覆盖个股硬条件,个股硬条件也不许质疑闸门。两层各审各的。

因子本身没什么神话。我用的全是教科书面孔:动量、低波动、流动性、估值分位。真正值得写的是打分流程里防自己的三个细节。

第一个:截面处理,不是时间序列处理。 因子分要在同一天的全体候选里横向比较,先 winsorize 掐掉两端离群值(最贵的那只票和跌麻的那只票都不许绑架整个截面),再转 z 分。缺失值补中位数而不是删行,删行会让“数据没到”的票变相获得免检,补中位是让缺数据的票拿中性分,老老实实排在中间。

第二个:权重先定死,再谈优化。 这是我在评分层挨过最狠的一课。第一版我把权重写成“可调参数”,允许每周审计微调,结果系统顺着行情一路追打:哪类票最近涨,权重就漂向哪类。两个月后回头看,它不是在做投资,是在做行情的复读机。后来我把权重锁成配置里的常量,改动要走“季度复盘”这种郑重其事的流程,改一次留一次历史。锁死的含义是:这份权重表达的是你对市场的立场,立场可以错,但必须可审计。一个永远在迎合最近行情的权重组合,等于没有立场。

第三个:因子有效性要用 rank IC 定期验尸,验出反向就认。 IC 检验的读法:|t|>2 且 IC 为正,因子在给你赚钱;|t|>2 且 IC 为负,因子在给你反向赚钱,它预测的东西和直觉正好相反;t 不显著,这个因子就是装饰。学术圈早就反复报告过 A 股的短期动量失效甚至反转(追高常常接盘,这与成熟市场的动量效应相反,Liu-Stambaugh-Yuan 那篇《Size and Value in China》和相关行为金融文献都有口径),我自己做截面检验时看到动量 IC 为负的那一刻还是愣了:文档里的常识和我自己的代码打架,最后是我的代码和 A 股的历史数据一起站在了“常识”的反面。认账的方式是降权、或者把动量改造成“倒 U”:适度上涨给高分,追涨过热反而低分。这比假装没看见体面,也比硬扛着高权重省钱。

下面是本机实跑的评分示意片段,宇宙是十只虚构标的(DEMO000 到 DEMO009),权重 0.5/0.3/0.2 是纯示例:

# 因子评分:截面 winsorize + z 分 + 固定权重(示例宇宙,10 只虚构标的)
import pandas as pd
import numpy as np

rng = np.random.default_rng(7)
df = pd.DataFrame({
    "code": [f"DEMO{i:03d}" for i in range(10)],
    "mom":  rng.normal(0, 1, 10),      # 动量
    "vol":  -abs(rng.normal(1, .4, 10)),# 低波动(取负号:波动越小分越高)
    "liq":  rng.normal(0, 1, 10),      # 流动性
})
df.loc[3, "mom"] = np.nan              # 故意留一个缺失

def cs_z(s, clip=3.0):
    s = s.clip(s.quantile(0.05), s.quantile(0.95))   # winsorize,掐掉离群值
    return (s - s.mean()) / (s.std() or 1)

W = {"mom": 0.5, "vol": 0.3, "liq": 0.2}   # 权重先定死,季度复盘才允许动
df["score"] = sum(w * cs_z(df[c].fillna(df[c].median())) for c, w in W.items())
print(df.sort_values("score", ascending=False)[["code","mom","vol","liq","score"]]
      .to_string(index=False, float_format=lambda v: f"{v:+.2f}"))

实跑输出:

code   mom   vol   liq  score
DEMO007 +1.34 -0.82 -0.54  +1.07
DEMO001 +0.30 -1.14 -0.24  +0.27
DEMO003   NaN -0.63 +0.27  +0.26
DEMO008 -0.49 -0.24 -0.05  +0.24
DEMO006 +0.06 -0.46 -2.52  +0.20
DEMO009 -0.62 -0.48 +0.11  +0.02
DEMO004 -0.45 -0.99 +0.16  -0.26
DEMO000 +0.00 -1.20 -1.84  -0.44
DEMO002 -0.27 -1.04 -1.27  -0.45
DEMO005 -0.99 -1.28 -0.19  -0.93

盯住 DEMO003 那行:动量缺失,中位数补上之后排第三,没有因为数据缺口被踢出局,也没有被凭空喂成高分。这就是“缺数据=中性分”的形状。

一句话:评分层的本事不在选出最灵的因子,在让每个打分环节都留下可以被翻案的记录。

※ ※ ※

※ ※ ※

🔬 回测方法学:好看得吓人的曲线,我先杀一半

闸门有了,评分有了,接下来是最危险的环节:证明这套东西有用。回测是量化的实验室,也是量化的造假车间。我的日报里每一个策略结论,进闸门参数之前都得过回测这道关,而这道关的设计目标不是“让它通过”,是“让它别轻易通过”。

第一刀砍未来函数。 三个最常见的藏身处:用当日收盘价在当日成交(你不可能在收盘的那一秒按下买入键,正确口径是信号 shift 一天、次日开盘撮合);用披露日早于公告日的财务数据(季报“属于”三月,但四月底才公布,防未来的近似口径是法定披露截止日,宁迟不假);停牌股用停牌前的旧价参与打分(僵尸价格会让排名系统性偏向没人交易的票)。这三处我全踩过,踩的时候曲线都“好看”,修的时候才发现好看是因为偷看了答案。

第二刀砍边界泄漏。 训练和测试切段时,如果信号持有 5 天,那测试段开头 5 天的收益窗口和训练段尾巴是重叠的,朴素的时间序列切分在这里就是作弊。这个叫 purged walk-forward 的做法是 López de Prado 在《Advances in Financial Machine Learning》里系统讲过的,业内早就是标准姿势,但我第一次自己实现还是切漏了,因为重叠不在日历边界上,在收益窗口里。

# purged walk-forward:训练/测试之间留 embargo,掐掉前视泄漏(示意骨架)
import pandas as pd
import numpy as np

rng = np.random.default_rng(1)
idx = pd.date_range("2024-01-01", periods=500, freq="B")
sig = pd.DataFrame({"date": idx,
                    "x": rng.normal(0, 1, 500),
                    "y": rng.normal(0.0004, 0.01, 500)})

HOLD = 5                                   # 持仓周期 5 天 -> 边界要清掉 5 天重叠
folds = []
for t0 in range(0, 400, 80):               # 4 折滚动
    tr_a, tr_b = t0, t0 + 200
    te_a, te_b = tr_b + HOLD, tr_b + HOLD + 50
    if te_b > len(sig): break
    folds.append({"fold": len(folds)+1,
                  "train": f"{sig.iloc[tr_a].date:%m-%d}~{sig.iloc[tr_b-1].date:%m-%d}",
                  "test":  f"{sig.iloc[te_a].date:%m-%d}~{sig.iloc[te_b-1].date:%m-%d}",
                  "purged_days": HOLD})
print(pd.DataFrame(folds).to_string(index=False))
print(f"\n被 purge 掉的边界样本共 {HOLD*len(folds)} 条,它们正是前视藏身的位置。")

实跑输出:

fold       train        test  purged_days
    1 01-01~10-04 10-14~12-20            5
    2 04-22~01-24 02-03~04-11            5
    3 08-12~05-16 05-26~08-01            5
    4 12-02~09-05 09-15~11-21            5

被 purge 掉的边界样本共 20 条,它们正是前视藏身的位置。
purged walk-forward 示意

第三刀砍幸存者。 本地日线库如果只含“今天还活着的股票”,退市股从历史里消失,而退市股在退市前通常又烂又跌,你的回测宇宙被悄悄美颜过。加不上全历史成分就至少要在结论里写明这个口径缺陷,装作没有是最坏的姿势。

第四刀砍“试出来的”。 一个策略跑一百个参数挑出最好看的曲线讲给我自己听,这不叫回测,这叫把噪声打印成海报。我的规矩:预注册。跑之前把参数区间、主判据、放弃条件写进日报模板里,跑完只许填数,不许改题。判据不过就归档进“阵亡策略”清单,那份清单现在比赚钱的清单厚。

还有一层诚实压在所有刀法之上:回放和回测永远含后视成分,因为公式是用同一段历史定的。我做过长区间滚动回放,闸门优化最大的确定性收益不是“多赚”,而是把“满仓空仓反复横跳”的形态压到极低频、同时砍掉追高带的仓位敞口。至于压到多少倍,那是实盘统计口径,不该登在公开文章里。择时体系在长期震荡市里跑不赢纯持有,这是它自己回测亲口告诉我的,我在日报里原样贴过,没给它递润色笔。

一句话:回测层的产出不是收益曲线,是一份“哪些结论不许信”的清单,剩下的才敢拿去喂闸门。

※ ※ ※

※ ※ ※

🪞 最难的一层:日报要审的是我

写到这才轮到最里层,也是这个系统对我真正的价值:自审计。日报每天不只汇报行情,还反过来核查它自己。

核查什么?举四类。数据新鲜度:本地库的最新日期和决策里引用的 as-of 日期对不上,直接标红,这意味着闸门用着过期的宽度数据做今天的判断。流水对账:模拟账户的每一笔现金变动,从交易流水重放一遍必须能对上余额,费率口径要和成交时点一致,对不上就是 P0 事故,说明有笔交易在账本外发生过。规则合规:环境分和仓位档位的映射不许自相矛盾,硬止损不许被任何状态关掉,参数不许漂出预注册的界。回执新鲜度:上一轮冒烟测试的结论,必须能对上当时的代码版本,版本变了旧回执一律作废重跑。

这套审计跑出来的东西一开始全是我自己的锅:写死在代码里没走配置的阈值、测试污染了线上状态、部署链路重跑把“当日快照”算成了两天前。每一条都被台账记下,重复出现要加倍扣分。我不给它加新功能的权限,排在修台账后面的队里。

为什么值得花这个功夫?因为量化系统里真正的风险不是策略亏钱,是系统在悄悄变形而你信它还和昨天一样。权重被行情漂走了,你以为是纪律;闸门被一次“临时调整”松了,你以为是例外;审计发现自己连续三天数据没动,你以为是巧合。日报每晚把这三件事打回原形,它不哄我。

这是对已经有一套自动化的人最狠的一条建议:给你的系统写一份它自己不敢看的日报,比给它加一个因子值钱。

一句话:让机器替你执行规则不难,难的是让机器每晚核对“你还在按规则活”。

※ ※ ※

※ ※ ※

说点没解决的

闸门迟滞带的宽度、评分权重的季度复盘节奏、审计台账里 P0 和 P1 的边界,这些我现在都只敢说“用过半年没出事”,不敢说最优。更远的方向我在试两件事:让回测层和闸门层对话(哪些参数域的回测置信度低,闸门的灰区就该更宽),以及把审计里“解释发生了什么”的部分接给语言模型、把“判定违规”的部分永远留在规则里。前者是架构题,后者是信任题,都还没做完。

下一篇想聊点生活里的:假期出门一趟,门票规则、体力约束、预算口径怎么喂给 AI 变成一条流水线,行程、备选、抢票日历一次生成。这里先埋半个头,不剧透。

如果你自己也在跑类似的“每晚自动干活”的东西,评论区聊聊你的系统最容易在哪一层骗你。这篇如果有点用,点个在看,让更多还在给浮亏写小作文的人刷到。

※ ※ ※

※ ※ ※

资料来源

  • ▪ López de Prado, Advances in Financial Machine Learning(purged K-fold / embargo 口径)
  • ▪ Liu, Stambaugh, Yuan, Size and Value in China(A 股因子异于成熟市场的公开学术口径)
  • ▪ 文中代码块均在本地 Python 3.11 + pandas/NumPy/DuckDB 环境对合成示意数据实跑,输出原样粘贴;宇宙标的为 DEMO 虚构代码,阈值与权重均为示例值,与任何真实交易系统无关
  • ▪ 系统架构为作者个人项目的通用化转述:不含真实 IP、作业名、脚本路径与策略参数;示例主机与路径(如 /home/deploy)均为文档保留/虚构写法,非作者真实环境
  • ▪ 模拟盘全部为虚拟本金,不接任何真实下单通道;本文不构成投资建议
  • ▪ 生成日期:2026-09-28

评论 (0)