akg-factor-bridge/test_events_pricing.py

201 lines
12 KiB
Python
Raw Normal View History

"""催化事件、事件日字段与定价状态的离线单测不连库。2026-09-08《量价研判链吸收方案》3.4。
钉住四件事
四类券商正向事件的定义各自成立深度覆盖看前 365 天有无覆盖与评级上调预测看同机构同预测期
180 天内的上一篇超预期看标题同一天多篇合并成一条并标复合窗口之外的不算
事件日字段事件前涨幅跳空日内收益收盘位置量比涨停各自算对行情不够时留空不硬算
没有事件的票按数据日算
定价状态四情形的规则一次定死台账 046四种各有样例缺字段时写明缺什么
卡上的文字与表格短写法
开发机没有 pandas 与数据库驱动时只给缺席的模块装最小桩 test_valuation.py 同一约定
跑法python3 test_events_pricing.py pytest test_events_pricing.py
"""
import sys
import types
_STUBS = ("pandas", "psycopg", "pymysql", "dotenv")
for _n in _STUBS:
if _n not in sys.modules:
try:
__import__(_n)
except ImportError:
_m = types.ModuleType(_n)
if _n == "pandas":
_m.DataFrame = type("DataFrame", (), {})
_m.Series = type("Series", (), {})
sys.modules[_n] = _m
import card # noqa: E402
import sources # noqa: E402
def t(name, cond):
assert cond, name
print(" ok", name)
DS = "2026-09-04"
K = "SZ002812"
def rep(date, *, typ="点评", title="跟踪点评", rating="买入", org="", quarter="2026Q4", eps=2.0, k=K):
return {"k": k, "date": date, "type": typ, "title": title, "rating": rating, "org": org,
"quarter": quarter, "eps": eps}
def test_events():
print("四类事件")
rows = [rep("2026-09-03", typ="深度", title="深度报告:迎来拐点", rating="买入", org="")]
ev = sources.analyst_events([K], DS, rows=rows)[K]
t("前 365 天无覆盖的深度买入 -> 深度覆盖", ev["events"][0]["types"] == [sources.EV_DEEP])
rows2 = rows + [rep("2026-01-15", org="")]
t("前 365 天有覆盖就不算深度覆盖", K not in sources.analyst_events([K], DS, rows=rows2))
rows3 = [rep("2026-09-03", typ="深度", rating="中性", org="")]
t("深度但评级不是买入类不算", K not in sources.analyst_events([K], DS, rows=rows3))
rows = [rep("2026-06-20", org="", eps=2.0), rep("2026-09-01", org="", eps=3.1)]
ev = sources.analyst_events([K], DS, rows=rows)[K]
t("同机构同预测期 180 天内上调五成以上 -> 上调盈利预测",
ev["events"][0]["date"] == "2026-09-01" and ev["events"][0]["types"] == [sources.EV_UPGRADE])
rows = [rep("2026-06-20", org="", eps=2.0), rep("2026-09-01", org="", eps=2.5)]
t("只上调两成五不算", K not in sources.analyst_events([K], DS, rows=rows))
rows = [rep("2026-06-20", org="", eps=2.0, quarter="2027Q4"), rep("2026-09-01", org="", eps=3.1)]
t("预测期不同不算", K not in sources.analyst_events([K], DS, rows=rows))
rows = [rep("2025-12-01", org="", eps=2.0), rep("2026-09-01", org="", eps=3.1)]
t("上一篇超过 180 天不算", K not in sources.analyst_events([K], DS, rows=rows))
rows = [rep("2026-06-20", org="", eps=-0.5), rep("2026-09-01", org="", eps=1.0)]
t("上一篇为负不算比例", K not in sources.analyst_events([K], DS, rows=rows))
rows = [rep("2026-08-28", title="2026 中报点评:业绩超预期,产能释放")]
ev = sources.analyst_events([K], DS, rows=rows)[K]
t("标题含超预期", ev["events"][0]["types"] == [sources.EV_BEAT] and not ev["events"][0]["compound"])
rows = [rep("2026-06-20", org="", eps=2.0),
rep("2026-09-01", org="", eps=3.1, title="业绩超预期"),
rep("2026-09-01", org="", typ="深度", title="深度:业绩超预期")]
ev = sources.analyst_events([K], DS, rows=rows)[K]
e0 = ev["events"][0]
t("同一篇同时上调与超预期 -> 复合;同一天多篇合并成一条、机构合在一起",
e0["compound"] and set(e0["types"]) == {sources.EV_BEAT, sources.EV_UPGRADE}
and e0["n_reports"] == 2 and e0["orgs"] == ["", ""])
t("深度那篇因为同期有覆盖不算深度覆盖", sources.EV_DEEP not in e0["types"])
rows = [rep("2026-06-20", org="", eps=2.0), rep("2026-09-01", org="", eps=3.1, title="业绩超预期"),
rep("2026-09-01", org="", typ="深度")]
e0 = sources.analyst_events([K], DS, rows=rows)[K]["events"][0]
t("同一天另一篇没有命中任何事件的研报不计入机构与篇数", e0["n_reports"] == 1 and e0["orgs"] == [""])
rows = [rep("2026-06-30", title="业绩超预期"), rep("2026-09-02", title="业绩超预期"), rep("2026-09-05", title="业绩超预期")]
ev = sources.analyst_events([K], DS, rows=rows)[K]
t("窗口60 天之前与数据日之后的都不算,最新在前", [e["date"] for e in ev["events"]] == ["2026-09-02"] and ev["latest"] == "2026-09-02")
t("没有研报行的票不在结果里", "SH600000" not in sources.analyst_events([K, "SH600000"], DS, rows=rows))
t("数据日不合法返回空", sources.analyst_events([K], "不是日期", rows=rows) == {})
def bar(date, o, h, l, c, pre=None, pct=None, vol=1000.0):
return {"date": date, "open": o, "high": h, "low": l, "close": c, "pre_close": pre,
"pct": pct, "volume": vol}
def hist_rows(n=30, base=10.0, step=0.0, last=None):
"""n 根平淡的 K 线,最后一根可替换。"""
rows = []
for i in range(n):
px = base + step * i
rows.append(bar(f"2026-08-{i + 1:02d}" if i < 31 else f"2026-09-{i - 30:02d}", px, px * 1.01, px * 0.99, px, pre=px, pct=0.0, vol=1000.0))
if last:
rows[-1] = last
return rows
def test_event_day_fields():
print("事件日字段")
# 事件日:跳空 3% 高开、日内再涨 2%、收在区间高位、量三倍、涨幅 5.06%
last = bar("2026-08-30", 10.3, 10.6, 10.25, 10.506, pre=10.0, pct=5.06, vol=3000.0)
hist = {K: hist_rows(30, last=last)}
f = sources.event_day_fields([K], DS, {K: {"latest": "2026-08-30"}}, hist=hist)[K]
t("事件日取事件当天那根", f["event_date"] == "2026-08-30" and f["has_event"])
t("跳空 3%、日内 2%、当日涨幅 5.06%",
f["gap"] == 0.03 and f["intraday"] == 0.02 and f["day_pct"] == 0.0506)
t("收盘位置 0.73、量比 3.0、不涨停",
round(f["close_pos"], 2) == 0.73 and f["vol_ratio"] == 3.0 and f["limit_up"] is False)
t("事件前 5 日与 20 日涨幅(平的行情)为零", f["pre5"] == 0.0 and f["pre20"] == 0.0)
hist = {K: hist_rows(30, base=10.0, step=0.1, last=bar("2026-08-30", 13.0, 13.2, 12.9, 13.1, pre=12.8, pct=2.34, vol=1000.0))}
f = sources.event_day_fields([K], DS, {K: {"latest": "2026-08-30"}}, hist=hist)[K]
t("事件前 20 日涨幅按事件前一日对二十一日前算", round(f["pre20"], 3) == round(12.8 / 10.8 - 1, 3))
t("创业板涨停线 19.8:科创板代码 5% 不算涨停",
sources.event_day_fields(["SH688001"], DS, {}, hist={"SH688001": hist_rows(30, last=bar("2026-08-30", 10, 10.6, 10, 10.5, pre=10, pct=5.0))})["SH688001"]["limit_up"] is False)
t("主板 9.9% 算涨停",
sources.event_day_fields([K], DS, {}, hist={K: hist_rows(30, last=bar("2026-08-30", 10, 11, 10, 10.99, pre=10, pct=9.9))})[K]["limit_up"] is True)
f = sources.event_day_fields([K], DS, {}, hist={K: hist_rows(3)})[K]
t("没有事件按数据日(最后一根),历史不够时 20 日涨幅与量比为空",
not f["has_event"] and f["pre20"] is None and f["vol_ratio"] is None and f["gap"] == 0.0)
t("事件日晚于行情最后一根时取不晚于事件日的最后一根",
sources.event_day_fields([K], DS, {K: {"latest": "2026-09-30"}}, hist={K: hist_rows(30)})[K]["event_date"] == "2026-08-30")
t("没有行情的票不在结果里", "SH600000" not in sources.event_day_fields([K, "SH600000"], DS, {}, hist={K: hist_rows(30)}))
t("行情表读失败返回空字典", sources.price_history([K], DS, read_mysql=lambda *a: (_ for _ in ()).throw(OSError("x")), code_col="symbol") == {})
seen = {}
def _reader(which, sql, params):
seen["sql"], seen["params"] = " ".join(sql.split()), params
return [{"ts_code": "SZ002812", "d": "2026-09-04", "open": "10", "high": "11", "low": "9", "close": "10.5",
"pre_close": 10, "percent": 5.0, "volume": 100}]
ph = sources.price_history(["002812.SZ", K], DS, read_mysql=_reader, code_col="symbol")
t("行情表按前缀式代码查、区间左闭右开、去重代码",
seen["params"] == ("2026-05-27", "2026-09-05", "SZ002812") and "`symbol` IN (%s)" in seen["sql"]
and ph[K][0]["close"] == 10.5)
def fields(**kw):
base = {"event_date": "2026-08-30", "has_event": True, "pre5": 0.01, "pre20": 0.02, "gap": 0.0,
"intraday": 0.01, "close_pos": 0.8, "vol_ratio": 2.0, "day_pct": 0.03, "limit_up": False}
base.update(kw)
return base
def test_pricing_state():
print("定价状态四情形")
p = card.pricing_state(fields())
t("事件前没涨、事件日放量收高 -> 价格发现", p["state"] == card.PRICING_DISCOVERY)
p = card.pricing_state(fields(pre20=0.08))
t("事件前已涨 8%、事件日仍放量收高 -> 趋势延续", p["state"] == card.PRICING_CONTINUE)
p = card.pricing_state(fields(pre20=0.15, gap=0.03, intraday=-0.02, close_pos=0.2, day_pct=0.01))
t("事件前大涨、事件日放量跳空冲高回落 -> 高位兑现", p["state"] == card.PRICING_CASHOUT)
p = card.pricing_state(fields(pre20=0.06, gap=0.0, intraday=-0.02, close_pos=0.2, day_pct=-0.01))
t("事件前涨 6% 且冲高回落但不到 10% -> 震荡消化", p["state"] == card.PRICING_DIGEST)
p = card.pricing_state(fields(vol_ratio=1.0))
t("量比不够 -> 震荡消化", p["state"] == card.PRICING_DIGEST)
p = card.pricing_state(fields(day_pct=-0.01))
t("收在高位但当日下跌 -> 不算确认,震荡消化", p["state"] == card.PRICING_DIGEST)
p = card.pricing_state(fields(pre20=None))
t("缺 20 日涨幅 -> 不归类并写明", p["state"] is None and "事件前 20 日涨幅" in p["why"])
t("没有字段 -> None", card.pricing_state(None) is None and card.pricing_state({}) is None)
print("卡上的文字")
p = card.pricing_state(fields())
line = card.pricing_view(p)
t("整句带情形、依据与六个数", line.startswith("定价状态(事件日 2026-08-30价格发现。") and "量比 2.0" in line and "收盘位置 0.80" in line)
t("短写法", card.pricing_short(p) == "价格发现" and card.pricing_short(None) == ""
and card.pricing_short(card.pricing_state(fields(pre20=None))) == "算不出")
t("无事件按数据日的整句写明", "无事件,按数据日" in card.pricing_view(card.pricing_state(fields(has_event=False))))
ev = {"latest": "2026-09-01", "count": 2, "events": [
{"date": "2026-09-01", "types": ["上调盈利预测", "业绩超预期"], "orgs": ["", ""], "title": "x", "n_reports": 2, "compound": True},
{"date": "2026-08-20", "types": ["深度覆盖"], "orgs": [""], "title": "y", "n_reports": 1, "compound": False}]}
t("催化事件整句", card.events_view(ev) == "催化事件(近 60 天 2 天有事件2026-09-01 上调盈利预测与业绩超预期甲、丁复合2026-08-20 深度覆盖(乙)")
t("催化事件短写法", card.events_short(ev) == "09-01 上调盈利预测与业绩超预期(复合)" and card.events_short(None) == "")
t("没有事件的整句", "没有券商正向事件" in card.events_view(None))
def main():
test_events()
test_event_day_fields()
test_pricing_state()
print("ALL OK — 四类事件 / 事件日字段 / 定价状态四情形 / 卡上文字 全部通过")
if __name__ == "__main__":
main()