1187 lines
51 KiB
Python
1187 lines
51 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import sys
|
||
from datetime import datetime
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
APP_DIR = Path(__file__).resolve().parent
|
||
VENDOR_DIR = APP_DIR / "vendor"
|
||
ICHING_DATA_FILE = APP_DIR / "data" / "iching_zh.json"
|
||
if str(VENDOR_DIR) not in sys.path:
|
||
sys.path.insert(0, str(VENDOR_DIR))
|
||
|
||
from lunar_python import Solar # noqa: E402
|
||
from lunar_python.util import LunarUtil # noqa: E402
|
||
|
||
|
||
TRIGRAM_NAMES = {
|
||
(1, 1, 1): "乾",
|
||
(1, 1, 0): "兑",
|
||
(1, 0, 1): "离",
|
||
(1, 0, 0): "震",
|
||
(0, 1, 1): "巽",
|
||
(0, 1, 0): "坎",
|
||
(0, 0, 1): "艮",
|
||
(0, 0, 0): "坤",
|
||
}
|
||
|
||
LINE_POSITIONS = ("初爻", "二爻", "三爻", "四爻", "五爻", "上爻")
|
||
LINE_ROLES = (
|
||
("地", "内", "个股内核"),
|
||
("地", "外", "个股外显"),
|
||
("人", "内", "行业内核"),
|
||
("人", "外", "行业外显"),
|
||
("天", "内", "指数内核"),
|
||
("天", "外", "指数外显"),
|
||
)
|
||
|
||
STEM_MOVEMENT = {
|
||
"甲": "土", "己": "土",
|
||
"乙": "金", "庚": "金",
|
||
"丙": "水", "辛": "水",
|
||
"丁": "木", "壬": "木",
|
||
"戊": "火", "癸": "火",
|
||
}
|
||
MOVEMENT_PAIR = {
|
||
"土": "甲己化土",
|
||
"金": "乙庚化金",
|
||
"水": "丙辛化水",
|
||
"木": "丁壬化木",
|
||
"火": "戊癸化火",
|
||
}
|
||
YANG_STEMS = set("甲丙戊庚壬")
|
||
STEM_ELEMENT = {
|
||
"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
|
||
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水",
|
||
}
|
||
BRANCH_ELEMENT = {
|
||
"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", "巳": "火",
|
||
"午": "火", "未": "土", "申": "金", "酉": "金", "戌": "土", "亥": "水",
|
||
}
|
||
SUIHUI_BRANCHES = set("子丑卯辰午未酉戌")
|
||
SITIAN = {
|
||
"子": "少阴君火", "午": "少阴君火",
|
||
"丑": "太阴湿土", "未": "太阴湿土",
|
||
"寅": "少阳相火", "申": "少阳相火",
|
||
"卯": "阳明燥金", "酉": "阳明燥金",
|
||
"辰": "太阳寒水", "戌": "太阳寒水",
|
||
"巳": "厥阴风木", "亥": "厥阴风木",
|
||
}
|
||
ZAIQUAN = {
|
||
"少阴君火": "阳明燥金",
|
||
"太阴湿土": "太阳寒水",
|
||
"少阳相火": "厥阴风木",
|
||
"阳明燥金": "少阴君火",
|
||
"太阳寒水": "太阴湿土",
|
||
"厥阴风木": "少阳相火",
|
||
}
|
||
# 客气次序(一阴→二阴→三阴→一阳→二阳→三阳)。
|
||
QI_SEQUENCE = ("厥阴风木", "少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水")
|
||
# 主气次序(固定,按五行相生:木→君火→相火→湿土→燥金→寒水)。
|
||
HOST_QI_SEQUENCE = ("厥阴风木", "少阴君火", "少阳相火", "太阴湿土", "阳明燥金", "太阳寒水")
|
||
QI_ELEMENT = {
|
||
"厥阴风木": "木", "少阴君火": "火", "太阴湿土": "土",
|
||
"少阳相火": "火", "阳明燥金": "金", "太阳寒水": "水",
|
||
}
|
||
STEP_NAMES = ("初之气", "二之气", "三之气", "四之气", "五之气", "终之气")
|
||
PHASE_INFO = {
|
||
"木": {"motion": "生发、扩散、延展", "mind": "更愿意尝试新方向,也容易高估成长斜率"},
|
||
"火": {"motion": "显化、加速、躁动", "mind": "注意力集中、追逐速度,也容易冲动和过度一致"},
|
||
"土": {"motion": "承载、黏合、迟滞", "mind": "偏好确定和稳定,也可能出现犹豫与路径依赖"},
|
||
"金": {"motion": "收敛、裁决、肃降", "mind": "纪律和风险意识增强,也容易形成快速杀估值"},
|
||
"水": {"motion": "流动、潜藏、下行", "mind": "资金更重视流动性和退路,也可能放大恐惧传染"},
|
||
}
|
||
PHASE_BEHAVIOR = {
|
||
"木": {
|
||
"emotion": "求新与扩张感增强,容易对新题材迅速产生期待",
|
||
"bias": "倾向先看到成长空间,再补风险验证",
|
||
"operation": "更想试仓、开新方向或给趋势更高估值",
|
||
"risk": "防止把萌芽当成主升,把想象力当成确认",
|
||
"balance": "先写清验证条件,等分歧后的承接再决定是否加码",
|
||
},
|
||
"火": {
|
||
"emotion": "兴奋、急迫和表现欲更容易被放大,群体注意力趋于集中",
|
||
"bias": "倾向追逐速度与一致性,低估高位拥挤和冲动成本",
|
||
"operation": "更容易追涨、抢先手、放宽原有仓位上限",
|
||
"risk": "防止情绪高潮时把一致误作确定,把速度误作安全",
|
||
"balance": "延迟一次下单冲动,用成交承接和次日反馈替代情绪确认",
|
||
},
|
||
"土": {
|
||
"emotion": "对确定性和安全感的需求上升,也容易迟疑、黏滞",
|
||
"bias": "倾向依赖熟悉路径,对已经持有的判断更难松手",
|
||
"operation": "更容易守仓、等确认,或因不愿认错而延迟处理",
|
||
"risk": "防止把稳定感当作低风险,把犹豫当作耐心",
|
||
"balance": "把持仓理由量化,触发失效条件时按计划减法处理",
|
||
},
|
||
"金": {
|
||
"emotion": "警觉、挑剔和裁决感增强,容错意愿下降",
|
||
"bias": "倾向快速分辨强弱,也可能过早否定尚在修复的机会",
|
||
"operation": "更容易止损、兑现、收缩仓位并集中到辨识度高的标的",
|
||
"risk": "防止在恐慌扩散时机械割裂,也防止过度追求完美买点",
|
||
"balance": "区分逻辑失效与价格波动,给修复保留一个观察窗口",
|
||
},
|
||
"水": {
|
||
"emotion": "不确定感与避险意识上升,消息和恐惧更容易传染",
|
||
"bias": "倾向先寻找退路,可能放大流动性风险或反复试探",
|
||
"operation": "更容易降仓、观望、快进快出,偏好有流动性的方向",
|
||
"risk": "防止因想象最坏结果而在低流动性时点失去判断",
|
||
"balance": "降低频率,保留现金与预案,只处理能清楚定义风险的交易",
|
||
},
|
||
}
|
||
ELEMENT_GENERATES = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||
ELEMENT_CONTROLS = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||
SECTOR_PHASE_RULES = {
|
||
"木": (
|
||
# 植物生长类 + 仁术(医) + 教化(教育) + 纤维文书
|
||
"农业", "种植", "种业", "林业", "园林", "畜牧", "养殖", "饲料",
|
||
"医药", "中药", "生物医药", "创新药", "医疗", "疫苗",
|
||
"教育", "培训", "出版", "图书",
|
||
"纺织", "服装", "服饰", "家纺", "造纸", "印刷", "包装",
|
||
"家具", "家居", "木材", "烟草",
|
||
),
|
||
"火": (
|
||
# 光热能源 + 电子传媒 + 炉灶
|
||
"电力", "火电", "光伏", "太阳能", "风电", "储能", "电池", "锂电",
|
||
"充电桩", "新能源", "核电", "煤炭", "石油", "石化", "燃气",
|
||
"电子", "半导体", "芯片", "集成电路", "消费电子", "光学", "光电",
|
||
"显示", "面板", "通信", "计算机", "软件", "互联网", "游戏",
|
||
"人工智能", "数据", "云计算", "传媒", "影视", "广告", "娱乐", "直播",
|
||
),
|
||
"土": (
|
||
# 不动产 + 营造 + 稼穑饮食(土主养育)
|
||
"地产", "房地产", "物业", "建筑", "基建", "工程", "路桥",
|
||
"建材", "水泥", "玻璃", "陶瓷", "混凝土", "管材", "防水",
|
||
"食品", "乳业", "肉制品", "调味品", "农产品加工",
|
||
"零售", "百货", "仓储",
|
||
),
|
||
"金": (
|
||
# 金属机械 + 财帛裁决 + 兵戈肃杀
|
||
"银行", "证券", "保险", "期货", "信托", "金融", "支付",
|
||
"钢铁", "有色", "金属", "贵金属", "黄金", "稀土",
|
||
"机械", "设备", "机床", "机器人", "仪器", "仪表",
|
||
"汽车", "整车", "零部件", "家电", "五金",
|
||
"军工", "国防", "兵器", "船舶", "航天",
|
||
),
|
||
"水": (
|
||
# 流动运输 + 液体 + 商旅(水主流、主智)
|
||
"航运", "港口", "物流", "快递", "运输", "航空", "机场",
|
||
"水务", "供水", "污水", "水利", "环保",
|
||
"饮料", "白酒", "啤酒", "黄酒",
|
||
"化工", "化学", "化纤",
|
||
"旅游", "酒店", "餐饮", "水产", "渔业", "贸易", "商贸",
|
||
),
|
||
}
|
||
|
||
|
||
def build_market_hexagram(
|
||
dashboard: dict[str, Any],
|
||
recent_history: list[dict[str, Any]],
|
||
index_context: dict[str, Any] | None = None,
|
||
sector_name: str = "",
|
||
stock_code: str = "",
|
||
external_stock: dict[str, Any] | None = None,
|
||
external_sector: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
sectors = list(dashboard.get("sectors") or [])
|
||
limits = list(dashboard.get("limits") or [])
|
||
broken = list(dashboard.get("broken") or [])
|
||
down_limits = list(dashboard.get("down_limits") or [])
|
||
normalized_sector = sector_name.strip().lower()
|
||
external_sector = external_sector or {}
|
||
selected_sector = next(
|
||
(
|
||
item for item in sectors
|
||
if str(item.get("name") or "").strip().lower() == normalized_sector
|
||
or (normalized_sector and normalized_sector in str(item.get("name") or "").strip().lower())
|
||
),
|
||
None,
|
||
)
|
||
if external_sector:
|
||
selected_sector = external_sector
|
||
external_stock = external_stock or {}
|
||
external_stock_sector = str(external_stock.get("sector") or "").strip()
|
||
if selected_sector is None and external_stock_sector:
|
||
selected_sector = next((item for item in sectors if item.get("name") == external_stock_sector), None)
|
||
if selected_sector is None and external_stock:
|
||
selected_sector = {
|
||
"name": external_stock_sector or sector_name.strip() or "个股所属行业",
|
||
"leader": external_stock.get("name") or "--",
|
||
"change": external_stock.get("change") or 0,
|
||
"strength": max(0, min(100, 50 + float(external_stock.get("change") or 0) * 3)),
|
||
"amount_billion": external_stock.get("amount_billion") or 0,
|
||
"count": 0,
|
||
"max_streak": 0,
|
||
}
|
||
selected_sector = selected_sector or (sectors[0] if sectors else {})
|
||
actual_sector = str(selected_sector.get("name") or "暂无热点")
|
||
sector_stocks = [row for row in limits + broken + down_limits if row.get("sector") == actual_sector]
|
||
selected_stock = next((row for row in sector_stocks if str(row.get("code")) == stock_code), None)
|
||
if selected_stock is None and external_stock:
|
||
selected_stock = external_stock
|
||
if selected_stock is None and selected_sector.get("leader"):
|
||
selected_stock = next(
|
||
(row for row in sector_stocks if row.get("name") == selected_sector.get("leader")),
|
||
None,
|
||
)
|
||
selected_stock = selected_stock or (sector_stocks[0] if sector_stocks else (limits[0] if limits else {}))
|
||
|
||
scores = _market_line_scores(
|
||
dashboard,
|
||
recent_history,
|
||
index_context or {},
|
||
selected_sector,
|
||
selected_stock,
|
||
limits,
|
||
)
|
||
values = [_score_to_line(item["score"]) for item in scores]
|
||
hexagram = hexagram_from_lines(values)
|
||
for index, (line, score) in enumerate(zip(hexagram["lines"], scores)):
|
||
talent, layer, role = LINE_ROLES[index]
|
||
line.update(
|
||
{
|
||
"talent": talent,
|
||
"layer": layer,
|
||
"role": role,
|
||
"score": round(score["score"], 3),
|
||
"evidence": score["evidence"],
|
||
}
|
||
)
|
||
pair_readings = []
|
||
for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)):
|
||
inner = scores[inner_index]["score"]
|
||
outer = scores[outer_index]["score"]
|
||
if inner >= 0 and outer >= 0:
|
||
state = "内外相应,势有承载"
|
||
elif inner < 0 <= outer:
|
||
state = "外强内弱,表里有差"
|
||
elif inner >= 0 > outer:
|
||
state = "内强外抑,势待显化"
|
||
else:
|
||
state = "内外皆弱,宜守不宜躁"
|
||
pair_readings.append({"level": label, "state": state, "inner": round(inner, 3), "outer": round(outer, 3)})
|
||
|
||
options = []
|
||
for sector in sectors[:20]:
|
||
name = str(sector.get("name") or "")
|
||
stocks = [row for row in limits + broken + down_limits if row.get("sector") == name]
|
||
options.append(
|
||
{
|
||
"name": name,
|
||
"leader": sector.get("leader") or "",
|
||
"stocks": [
|
||
{"code": str(row.get("code") or ""), "name": row.get("name") or "--", "status": row.get("status") or ""}
|
||
for row in stocks[:20]
|
||
],
|
||
}
|
||
)
|
||
average_score = sum(item["score"] for item in scores) / 6
|
||
moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]]
|
||
movement = {
|
||
"moving_lines": hexagram["moving_lines"],
|
||
"moving_names": moving_names,
|
||
"label": (
|
||
f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}"
|
||
if moving_names
|
||
else f"无动爻,守{hexagram['name']}本势"
|
||
),
|
||
"explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。",
|
||
}
|
||
return {
|
||
"data_trade_date": str(dashboard.get("meta", {}).get("trade_date") or ""),
|
||
"sector": actual_sector,
|
||
"sector_code": str(selected_sector.get("code") or ""),
|
||
"sector_taxonomy": str(selected_sector.get("taxonomy") or ""),
|
||
"stock": {
|
||
"code": str(selected_stock.get("code") or ""),
|
||
"name": selected_stock.get("name") or "--",
|
||
"status": selected_stock.get("status") or "",
|
||
},
|
||
"selection_notice": (
|
||
"个股接口不可用,当前按演示行情补取。"
|
||
if external_stock.get("data_source") == "demo"
|
||
else ""
|
||
),
|
||
"hexagram": hexagram,
|
||
"movement": movement,
|
||
"pair_readings": pair_readings,
|
||
"momentum_score": round(average_score * 100),
|
||
"momentum_label": _momentum_label(average_score),
|
||
"sector_options": options,
|
||
"index_context": index_context or {},
|
||
}
|
||
|
||
|
||
def build_manual_market_hexagram(
|
||
values: list[int],
|
||
data_trade_date: str,
|
||
sector: dict[str, Any] | None,
|
||
stock: dict[str, Any] | None,
|
||
index_context: dict[str, Any] | None = None,
|
||
note: str = "",
|
||
) -> dict[str, Any]:
|
||
"""Build an explicitly user-calibrated chart without pretending it is market data."""
|
||
hexagram = hexagram_from_lines(values)
|
||
score_map = {6: -0.85, 8: -0.35, 7: 0.35, 9: 0.85}
|
||
scores = [score_map[value] for value in values]
|
||
value_names = {6: "老阴·动", 8: "少阴·静", 7: "少阳·静", 9: "老阳·动"}
|
||
for index, line in enumerate(hexagram["lines"]):
|
||
talent, layer, role = LINE_ROLES[index]
|
||
line.update(
|
||
{
|
||
"talent": talent,
|
||
"layer": layer,
|
||
"role": role,
|
||
"score": scores[index],
|
||
"evidence": [f"用户手动校准为{value_names[values[index]]}"],
|
||
}
|
||
)
|
||
|
||
pair_readings = []
|
||
for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)):
|
||
inner, outer = scores[inner_index], scores[outer_index]
|
||
if inner >= 0 and outer >= 0:
|
||
state = "内外相应,势有承载"
|
||
elif inner < 0 <= outer:
|
||
state = "外强内弱,表里有差"
|
||
elif inner >= 0 > outer:
|
||
state = "内强外抑,势待显化"
|
||
else:
|
||
state = "内外皆弱,宜守不宜躁"
|
||
pair_readings.append({"level": label, "state": state, "inner": inner, "outer": outer})
|
||
|
||
moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]]
|
||
movement = {
|
||
"moving_lines": hexagram["moving_lines"],
|
||
"moving_names": moving_names,
|
||
"label": (
|
||
f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}"
|
||
if moving_names else f"无动爻,守{hexagram['name']}本势"
|
||
),
|
||
"explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。",
|
||
}
|
||
average_score = sum(scores) / 6
|
||
sector = sector or {}
|
||
stock = stock or {}
|
||
return {
|
||
"data_trade_date": data_trade_date,
|
||
"sector": str(sector.get("name") or stock.get("sector") or "--"),
|
||
"sector_code": str(sector.get("code") or ""),
|
||
"sector_taxonomy": str(sector.get("taxonomy") or ""),
|
||
"stock": {
|
||
"code": str(stock.get("code") or ""),
|
||
"name": str(stock.get("name") or "--"),
|
||
"status": str(stock.get("status") or ""),
|
||
},
|
||
"selection_notice": "",
|
||
"hexagram": hexagram,
|
||
"movement": movement,
|
||
"pair_readings": pair_readings,
|
||
"momentum_score": round(average_score * 100),
|
||
"momentum_label": _momentum_label(average_score),
|
||
"sector_options": [],
|
||
"index_context": index_context or {},
|
||
"manual_calibration": True,
|
||
"calibration_note": note,
|
||
}
|
||
|
||
|
||
def build_five_phase_field(
|
||
trade_date: str,
|
||
sector_phase_overrides: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""构建指定日期的五运六气场。
|
||
|
||
本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换;
|
||
大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。
|
||
"""
|
||
compact = trade_date.replace("-", "")
|
||
if len(compact) != 8 or not compact.isdigit():
|
||
raise ValueError("日期格式应为 YYYY-MM-DD。")
|
||
year, month, day = int(compact[:4]), int(compact[4:6]), int(compact[6:])
|
||
# 公共气场以日期为最小粒度。固定取正午只为构造历法对象,不引入时辰权重。
|
||
solar = Solar.fromYmdHms(year, month, day, 12, 0, 0)
|
||
lunar = solar.getLunar()
|
||
year_gz = lunar.getYearInGanZhiExact()
|
||
month_gz = lunar.getMonthInGanZhiExact()
|
||
day_gz = lunar.getDayInGanZhiExact()
|
||
year_stem, year_branch = year_gz[0], year_gz[1]
|
||
movement_phase = STEM_MOVEMENT[year_stem]
|
||
movement_tendency = "太过" if year_stem in YANG_STEMS else "不及"
|
||
sitian = SITIAN[year_branch]
|
||
zaiquan = ZAIQUAN[sitian]
|
||
step = _current_qi_step(lunar, solar.toYmd())
|
||
host_qi = HOST_QI_SEQUENCE[step - 1]
|
||
sitian_index = QI_SEQUENCE.index(sitian)
|
||
guest_qi = QI_SEQUENCE[(sitian_index - 2 + step - 1) % 6]
|
||
prev_jie_qi = lunar.getPrevJieQi()
|
||
next_jie_qi = lunar.getNextJieQi()
|
||
|
||
# 年纲由中运与岁气共同建立。岁半以前司天为主,岁半以后在泉为主;
|
||
# 另一端仍保留背景作用,避免把天地升降误解为截然切断。
|
||
sitian_weight, zaiquan_weight = (15, 5) if step <= 3 else (5, 15)
|
||
year_weights = {element: 0.0 for element in PHASE_INFO}
|
||
_add_phase(year_weights, movement_phase, 30)
|
||
_add_phase(year_weights, QI_ELEMENT[sitian], sitian_weight)
|
||
_add_phase(year_weights, QI_ELEMENT[zaiquan], zaiquan_weight)
|
||
|
||
current_qi_weights = {element: 0.0 for element in PHASE_INFO}
|
||
_add_phase(current_qi_weights, QI_ELEMENT[host_qi], 20)
|
||
_add_phase(current_qi_weights, QI_ELEMENT[guest_qi], 25)
|
||
|
||
day_weights = {element: 0.0 for element in PHASE_INFO}
|
||
_add_phase(day_weights, STEM_MOVEMENT[day_gz[0]], 2.5)
|
||
_add_phase(day_weights, BRANCH_ELEMENT[day_gz[1]], 2.5)
|
||
|
||
weights = {
|
||
element: year_weights[element] + current_qi_weights[element] + day_weights[element]
|
||
for element in PHASE_INFO
|
||
}
|
||
total = sum(weights.values()) or 1
|
||
balance = [
|
||
{
|
||
"element": element,
|
||
"score": score,
|
||
"percent": round(score / total * 100),
|
||
**PHASE_INFO[element],
|
||
}
|
||
for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True)
|
||
]
|
||
overrides = sector_phase_overrides or {}
|
||
sector_catalog = _sector_phase_catalog(overrides)
|
||
dominant = balance[0]
|
||
secondary = balance[1]
|
||
year_dominant = _dominant_phase(year_weights)
|
||
current_qi_dominant = _dominant_phase(current_qi_weights)
|
||
day_dominant = _dominant_phase(day_weights)
|
||
guest_host_relation = _guest_host_relation(host_qi, guest_qi)
|
||
annual_pattern = _annual_qi_pattern(
|
||
movement_phase,
|
||
QI_ELEMENT[sitian],
|
||
year_branch,
|
||
)
|
||
annual_pattern_suffix = f";{annual_pattern['primary']}" if annual_pattern["primary"] else ""
|
||
ruling_qi = sitian if step <= 3 else zaiquan
|
||
ruling_label = "司天" if step <= 3 else "在泉"
|
||
alignment = ""
|
||
if guest_qi == sitian:
|
||
alignment = "司天同位"
|
||
elif guest_qi == zaiquan:
|
||
alignment = "在泉同位"
|
||
dominant_behavior = PHASE_BEHAVIOR[dominant["element"]]
|
||
secondary_behavior = PHASE_BEHAVIOR[secondary["element"]]
|
||
calendar_date = f"{year:04d}-{month:02d}-{day:02d}"
|
||
human_field = {
|
||
"summary": (
|
||
f"年以{year_dominant}为纲,当前{STEP_NAMES[step - 1]}由{ruling_label}{ruling_qi}主其半岁,"
|
||
f"客主呈{guest_host_relation['label']},日由{day_dominant}触发;"
|
||
f"合看以{dominant['element']}气偏显、{secondary['element']}气相随。{dominant_behavior['emotion']}。"
|
||
),
|
||
"emotional_tendency": [dominant_behavior["emotion"], secondary_behavior["emotion"]],
|
||
"decision_biases": [dominant_behavior["bias"], secondary_behavior["bias"]],
|
||
"operation_tendency": dominant_behavior["operation"],
|
||
"risk_reminders": [dominant_behavior["risk"], secondary_behavior["risk"]],
|
||
"balancing_actions": [dominant_behavior["balance"], secondary_behavior["balance"]],
|
||
}
|
||
return {
|
||
"date": calendar_date,
|
||
"lunar_date": f"农历{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}",
|
||
"pillars": {"year": year_gz, "month": month_gz, "day": day_gz},
|
||
"movement": {
|
||
"phase": movement_phase,
|
||
"tendency": movement_tendency,
|
||
"label": f"{movement_phase}运{movement_tendency}",
|
||
"basis": f"{year_stem}属{movement_phase}运,{year_stem}为{'阳干' if year_stem in YANG_STEMS else '阴干'}",
|
||
},
|
||
"six_qi": {
|
||
"sitian": sitian,
|
||
"zaiquan": zaiquan,
|
||
"step": step,
|
||
"step_name": STEP_NAMES[step - 1],
|
||
"host_qi": host_qi,
|
||
"guest_qi": guest_qi,
|
||
"ruling": ruling_label,
|
||
"ruling_qi": ruling_qi,
|
||
"alignment": alignment,
|
||
},
|
||
"solar_terms": {
|
||
"current": prev_jie_qi.getName(),
|
||
"current_at": prev_jie_qi.getSolar().toYmdHms(),
|
||
"next": next_jie_qi.getName(),
|
||
"next_at": next_jie_qi.getSolar().toYmdHms(),
|
||
},
|
||
"framework": {
|
||
"principle": "先立年纲,再察客气加临主气;岁半以前司天为主,岁半以后在泉为主,日辰只作触发。六气自大寒步进,岁气以立春为界。",
|
||
"weights": {
|
||
"year_movement": 30,
|
||
"sitian_zaiquan": 20,
|
||
"sitian": sitian_weight,
|
||
"zaiquan": zaiquan_weight,
|
||
"host_qi": 20,
|
||
"guest_qi": 25,
|
||
"day": 5,
|
||
},
|
||
"relations": {
|
||
"guest_host": guest_host_relation,
|
||
"annual_pattern": annual_pattern,
|
||
"alignment": alignment,
|
||
"ruling": {
|
||
"label": ruling_label,
|
||
"qi": ruling_qi,
|
||
"summary": f"当前由{ruling_label}{ruling_qi}主其半岁,另一端退居背景。",
|
||
},
|
||
},
|
||
"layers": [
|
||
{
|
||
"id": "year",
|
||
"label": "年纲",
|
||
"weight": 50,
|
||
"dominant": year_dominant,
|
||
"summary": (
|
||
f"{MOVEMENT_PAIR[movement_phase]},{movement_phase}运{movement_tendency};"
|
||
f"{ruling_label}{ruling_qi}当权"
|
||
f"{annual_pattern_suffix}"
|
||
),
|
||
"balance": _phase_distribution(year_weights),
|
||
},
|
||
{
|
||
"id": "current",
|
||
"label": "客主加临",
|
||
"weight": 45,
|
||
"dominant": current_qi_dominant,
|
||
"summary": (
|
||
f"当前{STEP_NAMES[step - 1]},客{guest_qi}加临主{host_qi};"
|
||
f"{guest_host_relation['label']},{guest_host_relation['tendency']}"
|
||
),
|
||
"balance": _phase_distribution(current_qi_weights),
|
||
},
|
||
{
|
||
"id": "day",
|
||
"label": "日辰触发",
|
||
"weight": 5,
|
||
"dominant": day_dominant,
|
||
"summary": f"{day_gz}日,{_movement_label(day_gz[0])};{day_gz[1]}属{BRANCH_ELEMENT[day_gz[1]]}、应{SITIAN[day_gz[1]]}",
|
||
"balance": _phase_distribution(day_weights),
|
||
},
|
||
],
|
||
},
|
||
"balance": balance,
|
||
"human_field": human_field,
|
||
"sector_catalog": sector_catalog,
|
||
"notice": "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。",
|
||
}
|
||
|
||
|
||
def build_personal_field(
|
||
birth_datetime: str,
|
||
gender: str,
|
||
current_date: str,
|
||
current_field: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
try:
|
||
born = datetime.strptime(birth_datetime, "%Y-%m-%dT%H:%M")
|
||
except ValueError as exc:
|
||
raise ValueError("出生时间格式应为 YYYY-MM-DDTHH:MM。") from exc
|
||
if not 1900 <= born.year <= 2100:
|
||
raise ValueError("出生年份应在 1900 至 2100 年之间。")
|
||
if gender not in {"male", "female", "unspecified"}:
|
||
raise ValueError("性别选项不正确。")
|
||
|
||
solar = Solar.fromYmdHms(born.year, born.month, born.day, born.hour, born.minute, 0)
|
||
lunar = solar.getLunar()
|
||
eight = lunar.getEightChar()
|
||
pillars = {
|
||
"year": eight.getYear(),
|
||
"month": eight.getMonth(),
|
||
"day": eight.getDay(),
|
||
"time": eight.getTime(),
|
||
}
|
||
visible_elements = {element: 0.0 for element in PHASE_INFO}
|
||
for key, pillar in pillars.items():
|
||
visible_elements[STEM_ELEMENT[pillar[0]]] += 1
|
||
visible_elements[BRANCH_ELEMENT[pillar[1]]] += 1.5 if key == "month" else 1
|
||
total = sum(visible_elements.values()) or 1
|
||
element_balance = [
|
||
{"element": element, "score": round(score, 1), "percent": round(score / total * 100)}
|
||
for element, score in sorted(visible_elements.items(), key=lambda item: item[1], reverse=True)
|
||
]
|
||
|
||
day_master = eight.getDayGan()
|
||
day_element = STEM_ELEMENT[day_master]
|
||
resource_element = next(element for element, generated in ELEMENT_GENERATES.items() if generated == day_element)
|
||
output_element = ELEMENT_GENERATES[day_element]
|
||
wealth_element = ELEMENT_CONTROLS[day_element]
|
||
officer_element = next(element for element, controlled in ELEMENT_CONTROLS.items() if controlled == day_element)
|
||
support_score = visible_elements[day_element] + visible_elements[resource_element]
|
||
if support_score < total * 0.42:
|
||
strength = "偏弱"
|
||
favorable = [resource_element, day_element]
|
||
caution = [officer_element, wealth_element, output_element]
|
||
balance_note = "日主支持偏少,简化算法倾向先取生扶,再看泄耗与制约是否过强。"
|
||
elif support_score > total * 0.62:
|
||
strength = "偏强"
|
||
favorable = [output_element, wealth_element, officer_element]
|
||
caution = [day_element, resource_element]
|
||
balance_note = "日主支持偏多,简化算法倾向用泄、耗、制来恢复流动。"
|
||
else:
|
||
strength = "相对平衡"
|
||
favorable = [output_element, wealth_element]
|
||
caution = [element_balance[0]["element"]]
|
||
balance_note = "五行支持与消耗接近,简化算法更看重当下偏盛元素的调节。"
|
||
|
||
ten_gods = {
|
||
"year": {"stem": eight.getYearShiShenGan(), "branches": eight.getYearShiShenZhi()},
|
||
"month": {"stem": eight.getMonthShiShenGan(), "branches": eight.getMonthShiShenZhi()},
|
||
"day": {"stem": "日主", "branches": eight.getDayShiShenZhi()},
|
||
"time": {"stem": eight.getTimeShiShenGan(), "branches": eight.getTimeShiShenZhi()},
|
||
}
|
||
ten_god_roles = {
|
||
day_element: "比劫",
|
||
resource_element: "印星",
|
||
output_element: "食伤",
|
||
wealth_element: "财星",
|
||
officer_element: "官杀",
|
||
}
|
||
|
||
compact = current_date.replace("-", "")
|
||
if len(compact) != 8 or not compact.isdigit():
|
||
raise ValueError("当前日期格式应为 YYYY-MM-DD。")
|
||
current_solar = Solar.fromYmdHms(int(compact[:4]), int(compact[4:6]), int(compact[6:]), 12, 0, 0)
|
||
current_lunar = current_solar.getLunar()
|
||
current_pillars = {
|
||
"year": current_lunar.getYearInGanZhiExact(),
|
||
"month": current_lunar.getMonthInGanZhiExact(),
|
||
"day": current_lunar.getDayInGanZhiExact(),
|
||
}
|
||
current_ten_gods = {
|
||
key: {
|
||
"pillar": pillar,
|
||
"stem": LunarUtil.SHI_SHEN.get(day_master + pillar[0]) or "--",
|
||
"branches": [LunarUtil.SHI_SHEN.get(day_master + gan) or "--" for gan in LunarUtil.ZHI_HIDE_GAN.get(pillar[1], [])],
|
||
}
|
||
for key, pillar in current_pillars.items()
|
||
}
|
||
field = current_field or build_five_phase_field(current_date)
|
||
dominant_elements = [item["element"] for item in field.get("balance", [])[:2]]
|
||
favorable_hits = [element for element in dominant_elements if element in favorable]
|
||
caution_hits = [element for element in dominant_elements if element in caution]
|
||
if favorable_hits and not caution_hits:
|
||
personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(favorable_hits)}较合你的平衡倾向,主观上更容易感到有支点。"
|
||
operation_note = "顺手感可能增强,但仍应把它当作自我状态提醒,不宜因此放宽交易纪律。"
|
||
elif caution_hits and not favorable_hits:
|
||
personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(caution_hits)}可能放大你的耗泄或压力感。"
|
||
operation_note = "更适合降低决策频率,尤其留意急于证明、犹豫不决或过早止损等惯性反应。"
|
||
else:
|
||
personal_tone = f"当日{'、'.join(dominant_elements)}并见,对你既有助力也有牵制,感受可能随情境切换。"
|
||
operation_note = "先辨认自己此刻是兴奋、恐惧还是执着,再决定是否需要行动。"
|
||
return {
|
||
"birth": {"datetime": birth_datetime, "gender": gender, "lunar": lunar.toString()},
|
||
"pillars": pillars,
|
||
"day_master": {"stem": day_master, "element": day_element, "strength": strength},
|
||
"ten_gods": ten_gods,
|
||
"ten_god_tendency": {
|
||
"favorable": [ten_god_roles[element] for element in favorable],
|
||
"caution": [ten_god_roles[element] for element in caution],
|
||
},
|
||
"element_balance": element_balance,
|
||
"balance_tendency": {
|
||
"favorable": favorable,
|
||
"caution": caution,
|
||
"note": balance_note,
|
||
"method": "按可见四柱五行、月令加权及日主生扶比例生成的简化平衡倾向,不等同于专业命理中的唯一喜用神结论。",
|
||
},
|
||
"current": {
|
||
"date": current_date,
|
||
"pillars": current_pillars,
|
||
"ten_gods": current_ten_gods,
|
||
"tone": personal_tone,
|
||
"operation_note": operation_note,
|
||
},
|
||
"notice": "个人结果仅供传统文化与自我观察使用。出生信息只在本机服务中计算。",
|
||
}
|
||
|
||
|
||
def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
|
||
if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values):
|
||
raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。")
|
||
bits = tuple(1 if value % 2 else 0 for value in values)
|
||
transformed_values = [7 if value == 6 else 8 if value == 9 else value for value in values]
|
||
transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values)
|
||
data = _iching_data()
|
||
primary = data.get(str(bits))
|
||
transformed = data.get(str(transformed_bits))
|
||
if not primary or not transformed:
|
||
raise ValueError("卦象数据不完整。")
|
||
lines = []
|
||
line_items = list(primary["lines"].values())
|
||
for index, (value, item) in enumerate(zip(values, line_items)):
|
||
lines.append(
|
||
{
|
||
"position": index + 1,
|
||
"position_name": LINE_POSITIONS[index],
|
||
"value": value,
|
||
"yin_yang": "阳" if value % 2 else "阴",
|
||
"moving": value in {6, 9},
|
||
"line_name": item["name"],
|
||
"text": item["text"],
|
||
"image": item.get("image") or "",
|
||
}
|
||
)
|
||
inner = TRIGRAM_NAMES[bits[:3]]
|
||
outer = TRIGRAM_NAMES[bits[3:]]
|
||
transformed_inner = TRIGRAM_NAMES[transformed_bits[:3]]
|
||
transformed_outer = TRIGRAM_NAMES[transformed_bits[3:]]
|
||
return {
|
||
"name": primary["name"],
|
||
"text": primary["text"],
|
||
"image": primary.get("image") or "",
|
||
"inner_trigram": inner,
|
||
"outer_trigram": outer,
|
||
"lines": lines,
|
||
"moving_lines": [index + 1 for index, value in enumerate(values) if value in {6, 9}],
|
||
"transformed": {
|
||
"name": transformed["name"],
|
||
"text": transformed["text"],
|
||
"image": transformed.get("image") or "",
|
||
"inner_trigram": transformed_inner,
|
||
"outer_trigram": transformed_outer,
|
||
},
|
||
}
|
||
|
||
|
||
def _market_line_scores(
|
||
dashboard: dict[str, Any],
|
||
recent_history: list[dict[str, Any]],
|
||
index_context: dict[str, Any],
|
||
sector: dict[str, Any],
|
||
stock: dict[str, Any],
|
||
limits: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
overview = dashboard.get("overview") or {}
|
||
stock_amount = float(stock.get("amount_billion") or 0)
|
||
stock_intraday = bool(stock.get("realtime")) or stock.get("_quantitative_mode") == "intraday"
|
||
if stock_intraday and stock.get("activity_source"):
|
||
amount_rank = _clamp(float(stock.get("amount_percentile") or 0) / 100)
|
||
turnover_relative = _clamp(
|
||
(float(stock.get("turnover_relative") or 0) - 1) / 1.5,
|
||
-1,
|
||
1,
|
||
)
|
||
volume_activity = _clamp(
|
||
(float(stock.get("volume_activity_ratio") or 0) - 1) / 1.5,
|
||
-1,
|
||
1,
|
||
)
|
||
stock_inner = _clamp(
|
||
(amount_rank * 2 - 1) * 0.35
|
||
+ turnover_relative * 0.35
|
||
+ volume_activity * 0.30,
|
||
-1,
|
||
1,
|
||
)
|
||
else:
|
||
amounts = [float(item.get("amount_billion") or 0) for item in limits]
|
||
amount_rank = (
|
||
_clamp(float(stock.get("amount_percentile") or 0) / 100)
|
||
if "amount_percentile" in stock
|
||
else _percentile(stock_amount, amounts)
|
||
)
|
||
turnover = _clamp(float(stock.get("turnover_rate") or 0) / 20)
|
||
seal = _clamp(float(stock.get("seal_amount_million") or 0) / 15000)
|
||
stability = 1 - _clamp(float(stock.get("open_times") or 0) / 6)
|
||
stock_inner_raw = 0.32 * amount_rank + 0.22 * turnover + 0.25 * seal + 0.21 * stability
|
||
stock_inner = stock_inner_raw * 2 - 1
|
||
stock_change = _clamp(float(stock.get("change") or 0) / 10, -1, 1)
|
||
streak = _clamp(float(stock.get("streak") or 0) / 5)
|
||
status_adjustment = -0.7 if stock.get("status") == "跌停" else -0.25 if stock.get("status") == "炸板" else 0.15
|
||
stock_outer = _clamp(stock_change * 0.7 + streak * 0.2 + status_adjustment, -1, 1)
|
||
|
||
rotation = next(
|
||
(item for item in dashboard.get("sector_rotation") or [] if item.get("name") == sector.get("name")),
|
||
{},
|
||
)
|
||
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
||
actual_sector_source = str(sector.get("source") or "").startswith("tushare_")
|
||
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
||
sector_change = float(sector.get("change") or 0)
|
||
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
||
sector_up = float(sector.get("up_count") or 0)
|
||
sector_down = float(sector.get("down_count") or 0)
|
||
sector_breadth = _clamp(
|
||
(sector_up - sector_down) / max(sector_up + sector_down, 1), -1, 1
|
||
)
|
||
relative_turnover_score = _clamp(
|
||
(float(sector.get("relative_turnover") or 0) - 1) / 1.5,
|
||
-1,
|
||
1,
|
||
)
|
||
leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1)
|
||
sector_inner = _clamp(
|
||
sector_breadth * 0.60 + relative_turnover_score * 0.40,
|
||
-1,
|
||
1,
|
||
)
|
||
sector_outer = _clamp(
|
||
sector_change_score * 0.90 + leading_score * 0.10,
|
||
-1,
|
||
1,
|
||
)
|
||
sector_inner_evidence = [
|
||
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
||
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
||
]
|
||
sector_outer_evidence = [
|
||
f"申万二级行业官方涨跌 {sector_change:+.2f}%",
|
||
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
||
]
|
||
elif actual_sector_source or sector_quantitative_mode == "historical":
|
||
sector_change = float(sector.get("change") or 0)
|
||
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
||
member_equal_change = float(sector.get("member_equal_change") if sector.get("member_equal_change") is not None else sector_change)
|
||
member_change_score = _clamp(member_equal_change / 5, -1, 1)
|
||
sector_up = float(sector.get("up_count") or 0)
|
||
sector_down = float(sector.get("down_count") or 0)
|
||
if sector_up + sector_down:
|
||
sector_breadth = _clamp((sector_up - sector_down) / (sector_up + sector_down), -1, 1)
|
||
else:
|
||
sector_breadth = sector_change_score
|
||
leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1)
|
||
sector_inner = _clamp(sector_breadth * 0.6 + member_change_score * 0.35 + leading_score * 0.05, -1, 1)
|
||
sector_outer = _clamp(sector_change_score * 0.9 + leading_score * 0.1, -1, 1)
|
||
sector_inner_evidence = [
|
||
f"行业上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
||
f"行业成分等权涨跌 {member_equal_change:+.2f}%",
|
||
]
|
||
sector_outer_evidence = [
|
||
f"{sector.get('name') or '--'}行业涨跌 {sector_change:+.2f}%",
|
||
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
||
]
|
||
else:
|
||
max_count = max([float(item.get("count") or 0) for item in dashboard.get("sectors") or []] or [1])
|
||
sector_count = _clamp(float(sector.get("count") or 0) / max_count)
|
||
sector_strength = _clamp(float(sector.get("strength") or 0) / 100)
|
||
sector_amount = _clamp(float(sector.get("amount_billion") or 0) / 100)
|
||
delta = _clamp(float(rotation.get("delta") or 0) / 8, -1, 1)
|
||
sector_inner = _clamp((sector_count * 0.35 + sector_strength * 0.35 + sector_amount * 0.2 + (delta + 1) / 2 * 0.1) * 2 - 1)
|
||
leader_change = _clamp(float(sector.get("change") or 0) / 10, -1, 1)
|
||
max_streak = _clamp(float(sector.get("max_streak") or 0) / 5)
|
||
sector_outer = _clamp(
|
||
leader_change * 0.45 + sector_strength * 0.25 + max_streak * 0.2 + delta * 0.1,
|
||
-1,
|
||
1,
|
||
)
|
||
sector_inner_evidence = [
|
||
f"{sector.get('name') or '--'}涨停 {int(sector.get('count') or 0)} 家,强度 {float(sector.get('strength') or 0):.0f}",
|
||
f"板块成交 {float(sector.get('amount_billion') or 0):.1f} 亿,家数变化 {float(rotation.get('delta') or 0):+.0f}",
|
||
]
|
||
sector_outer_evidence = [
|
||
f"领涨股 {sector.get('leader') or '--'},涨跌 {float(sector.get('change') or 0):+.2f}%",
|
||
f"最高 {int(sector.get('max_streak') or 0)} 板,轮动 {rotation.get('trend') or '暂无'}",
|
||
]
|
||
|
||
sentiment = _clamp(float(overview.get("sentiment_score") or 0) / 100)
|
||
seal_rate = _clamp(float(overview.get("seal_rate") or 0) / 100)
|
||
up_count = float(overview.get("up_count") or 0)
|
||
down_count = float(overview.get("down_count") or 0)
|
||
breadth = up_count / max(up_count + down_count, 1)
|
||
breadth_score = _clamp((breadth - 0.5) * 2, -1, 1)
|
||
current_amount = float(overview.get("amount_billion") or 0)
|
||
history_amounts = [float(item.get("amount_billion") or 0) for item in recent_history[:-1] if item.get("amount_billion")]
|
||
average_amount = (
|
||
float(overview.get("recent_average_amount_billion") or 0)
|
||
if "recent_average_amount_billion" in overview
|
||
else sum(history_amounts) / len(history_amounts) if history_amounts else current_amount
|
||
)
|
||
amount_change = _clamp((current_amount / max(average_amount, 1) - 1) * 3, -1, 1)
|
||
limit_up = float(overview.get("limit_up_count") or 0)
|
||
limit_down = float(overview.get("limit_down_count") or 0)
|
||
limit_balance = _clamp((limit_up - limit_down) / max(limit_up + limit_down, 1), -1, 1)
|
||
market_inner = _clamp(
|
||
(sentiment * 2 - 1) * 0.35
|
||
+ (seal_rate * 2 - 1) * 0.2
|
||
+ amount_change * 0.2
|
||
+ breadth_score * 0.15
|
||
+ limit_balance * 0.1,
|
||
-1,
|
||
1,
|
||
)
|
||
|
||
aggregate = index_context.get("aggregate") or {}
|
||
if aggregate:
|
||
index_change = _clamp(float(aggregate.get("average_pct_chg") or 0) / 3, -1, 1)
|
||
market_outer = index_change
|
||
index_evidence = [
|
||
f"主要指数平均涨跌 {float(aggregate.get('average_pct_chg') or 0):+.2f}%",
|
||
f"主要指数5日平均 {float(aggregate.get('average_return_5d') or 0):+.2f}%(趋势旁证,不参与外显阴阳)",
|
||
]
|
||
else:
|
||
market_outer = _clamp(breadth_score * 0.65 + limit_balance * 0.35, -1, 1)
|
||
index_evidence = ["指数接口不可用,以市场宽度和涨跌停结构代替"]
|
||
return [
|
||
{
|
||
"score": stock_inner,
|
||
"evidence": [
|
||
f"成交额 {stock_amount:.2f} 亿,全市场分位 {amount_rank * 100:.0f}%",
|
||
(
|
||
f"换手 {float(stock.get('turnover_rate') or 0):.2f}% / 市场 {float(stock.get('market_turnover_rate') or 0):.2f}%;"
|
||
f"同进度量能 {float(stock.get('volume_activity_ratio') or 0):.2f} 倍"
|
||
if stock_intraday
|
||
else f"换手率 {float(stock.get('turnover_rate') or 0):.2f}%,开板 {int(stock.get('open_times') or 0)} 次"
|
||
),
|
||
],
|
||
},
|
||
{
|
||
"score": stock_outer,
|
||
"evidence": [
|
||
f"{stock.get('name') or '--'}涨跌 {float(stock.get('change') or 0):+.2f}%",
|
||
f"状态 {stock.get('status') or '普通'},连板 {int(stock.get('streak') or 0)}",
|
||
],
|
||
},
|
||
{
|
||
"score": sector_inner,
|
||
"evidence": sector_inner_evidence,
|
||
},
|
||
{
|
||
"score": sector_outer,
|
||
"evidence": sector_outer_evidence,
|
||
},
|
||
{
|
||
"score": market_inner,
|
||
"evidence": [
|
||
f"情绪得分 {float(overview.get('sentiment_score') or 0):.0f},封板率 {float(overview.get('seal_rate') or 0):.1f}%",
|
||
f"成交额较近期均值 {amount_change / 3 * 100:+.1f}%,涨跌停 {int(limit_up)}:{int(limit_down)}",
|
||
],
|
||
},
|
||
{
|
||
"score": market_outer,
|
||
"evidence": index_evidence + [f"上涨 {int(up_count)} 家,下跌 {int(down_count)} 家"],
|
||
},
|
||
]
|
||
|
||
|
||
def _score_to_line(score: float) -> int:
|
||
if score >= 0.72:
|
||
return 9
|
||
if score >= 0:
|
||
return 7
|
||
if score <= -0.72:
|
||
return 6
|
||
return 8
|
||
|
||
|
||
def _momentum_label(score: float) -> str:
|
||
if score >= 0.45:
|
||
return "势盛而动"
|
||
if score >= 0.12:
|
||
return "势起未极"
|
||
if score > -0.12:
|
||
return "阴阳相持"
|
||
if score > -0.45:
|
||
return "势弱宜察"
|
||
return "势衰宜守"
|
||
|
||
|
||
def _current_qi_step(lunar: Any, ymd: str) -> int:
|
||
"""按六气分步边界返回当前步次。
|
||
|
||
本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换;
|
||
大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。
|
||
"""
|
||
current = int(ymd.replace("-", ""))
|
||
table = lunar.getJieQiTable()
|
||
boundaries = []
|
||
for name in ("大寒", "春分", "小满", "大暑", "秋分", "小雪"):
|
||
solar = table.get(name)
|
||
if solar is None:
|
||
continue
|
||
boundaries.append(int(solar.toYmd().replace("-", "")))
|
||
if len(boundaries) != 6:
|
||
return 1
|
||
if current < boundaries[0] or current >= boundaries[5]:
|
||
return 6
|
||
for index in range(5):
|
||
if boundaries[index] <= current < boundaries[index + 1]:
|
||
return index + 1
|
||
return 6
|
||
|
||
|
||
def _guest_host_relation(host_qi: str, guest_qi: str) -> dict[str, str]:
|
||
"""按客气加临主气的五行生克关系给出确定性判定。"""
|
||
host_element = QI_ELEMENT[host_qi]
|
||
guest_element = QI_ELEMENT[guest_qi]
|
||
if guest_element == host_element:
|
||
relation = {
|
||
"type": "same",
|
||
"label": "客主同气",
|
||
"order": "同气",
|
||
"tendency": "同类之气相并,得势则显,偏盛则亢",
|
||
}
|
||
elif ELEMENT_GENERATES[guest_element] == host_element:
|
||
relation = {
|
||
"type": "guest_generates_host",
|
||
"label": "客生主",
|
||
"order": "相得",
|
||
"tendency": "客气生助主气,气机较易相接",
|
||
}
|
||
elif ELEMENT_GENERATES[host_element] == guest_element:
|
||
relation = {
|
||
"type": "host_generates_guest",
|
||
"label": "主生客",
|
||
"order": "相生有泄",
|
||
"tendency": "主气生客,时令之力向外流转",
|
||
}
|
||
elif ELEMENT_CONTROLS[guest_element] == host_element:
|
||
relation = {
|
||
"type": "guest_controls_host",
|
||
"label": "客克主",
|
||
"order": "客胜为从",
|
||
"tendency": "客气制主,外来变化居于上风",
|
||
}
|
||
else:
|
||
relation = {
|
||
"type": "host_controls_guest",
|
||
"label": "主克客",
|
||
"order": "主胜为逆",
|
||
"tendency": "主气制客,时令与来气相持",
|
||
}
|
||
return {
|
||
**relation,
|
||
"host_qi": host_qi,
|
||
"host_element": host_element,
|
||
"guest_qi": guest_qi,
|
||
"guest_element": guest_element,
|
||
"basis": f"客{guest_element}加临主{host_element}",
|
||
}
|
||
|
||
|
||
def _annual_qi_pattern(
|
||
movement_element: str,
|
||
sitian_element: str,
|
||
year_branch: str,
|
||
) -> dict[str, Any]:
|
||
"""判定中运与岁气的天符、岁会及太乙天符核心格局。"""
|
||
is_tianfu = movement_element == sitian_element
|
||
is_suihui = (
|
||
year_branch in SUIHUI_BRANCHES
|
||
and movement_element == BRANCH_ELEMENT[year_branch]
|
||
)
|
||
names = []
|
||
if is_tianfu:
|
||
names.append("天符")
|
||
if is_suihui:
|
||
names.append("岁会")
|
||
primary = "太乙天符" if is_tianfu and is_suihui else (names[0] if names else "")
|
||
if primary == "太乙天符":
|
||
summary = "中运、司天与岁支同气,岁气相合尤著。"
|
||
elif primary == "天符":
|
||
summary = "中运与司天同气,运气相合。"
|
||
elif primary == "岁会":
|
||
summary = "中运与岁支五行同气,岁运相会。"
|
||
else:
|
||
summary = "中运、司天与岁支各循其位。"
|
||
return {
|
||
"primary": primary,
|
||
"names": names,
|
||
"is_tianfu": is_tianfu,
|
||
"is_suihui": is_suihui,
|
||
"summary": summary,
|
||
}
|
||
|
||
|
||
def _sector_phase_catalog(overrides: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
||
"""返回完整五行行业词表;精确手动归类可移动或新增词条。"""
|
||
manual = {
|
||
str(name).strip(): element
|
||
for name, element in (overrides or {}).items()
|
||
if str(name).strip() and element in PHASE_INFO
|
||
}
|
||
grouped: dict[str, list[dict[str, str]]] = {element: [] for element in PHASE_INFO}
|
||
seen: set[str] = set()
|
||
for default_element, keywords in SECTOR_PHASE_RULES.items():
|
||
for keyword in keywords:
|
||
if keyword in seen:
|
||
continue
|
||
seen.add(keyword)
|
||
target = manual.get(keyword, default_element)
|
||
grouped[target].append(
|
||
{
|
||
"name": keyword,
|
||
"classification_source": "manual" if keyword in manual else "builtin",
|
||
}
|
||
)
|
||
for name, element in manual.items():
|
||
if name in seen:
|
||
continue
|
||
seen.add(name)
|
||
grouped[element].append({"name": name, "classification_source": "manual"})
|
||
return [
|
||
{
|
||
"element": element,
|
||
"count": len(grouped[element]),
|
||
"industries": grouped[element],
|
||
}
|
||
for element in PHASE_INFO
|
||
]
|
||
|
||
|
||
def _sector_element(name: str, overrides: dict[str, str] | None = None) -> str:
|
||
normalized_name = name.strip()
|
||
manual_element = (overrides or {}).get(normalized_name)
|
||
if manual_element in PHASE_INFO:
|
||
return manual_element
|
||
best_element = "土"
|
||
best_keyword_length = 0
|
||
for element, keywords in SECTOR_PHASE_RULES.items():
|
||
for keyword in keywords:
|
||
if keyword in normalized_name and len(keyword) > best_keyword_length:
|
||
best_element = element
|
||
best_keyword_length = len(keyword)
|
||
return best_element
|
||
|
||
|
||
def _add_phase(weights: dict[str, float], element: str, amount: float) -> None:
|
||
weights[element] = weights.get(element, 0) + amount
|
||
|
||
|
||
def _dominant_phase(weights: dict[str, float]) -> str:
|
||
return max(weights.items(), key=lambda item: item[1])[0]
|
||
|
||
|
||
def _movement_label(stem: str) -> str:
|
||
phase = STEM_MOVEMENT[stem]
|
||
tendency = "太过" if stem in YANG_STEMS else "不及"
|
||
return f"{MOVEMENT_PAIR[phase]},{phase}运{tendency}"
|
||
|
||
|
||
def _phase_distribution(weights: dict[str, float]) -> list[dict[str, Any]]:
|
||
total = sum(weights.values()) or 1
|
||
return [
|
||
{"element": element, "score": score, "percent": round(score / total * 100)}
|
||
for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True)
|
||
if score > 0
|
||
]
|
||
|
||
|
||
def _percentile(value: float, values: list[float]) -> float:
|
||
clean = sorted(item for item in values if math.isfinite(item))
|
||
if not clean:
|
||
return 0.5
|
||
return sum(item <= value for item in clean) / len(clean)
|
||
|
||
|
||
def _clamp(value: float, minimum: float = 0, maximum: float = 1) -> float:
|
||
return max(minimum, min(maximum, value))
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _iching_data() -> dict[str, Any]:
|
||
payload = json.loads(ICHING_DATA_FILE.read_text(encoding="utf-8"))
|
||
data = payload.get("hexagrams") or {}
|
||
if len(data) != 64:
|
||
raise ValueError("六十四卦经典数据不完整。")
|
||
return data
|