from __future__ import annotations import csv import hashlib import io import json import math import os import tempfile from dataclasses import asdict, dataclass from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any, Iterable, Sequence import mysql.connector from .database import MySQLSettings, connect_target, safe_mysql_error SCHEMA_VERSION = 1 SHA256_LENGTH = 64 V1_POSITION_LABELS = { "BELOW_BASE_RANGE": "偏低", "WITHIN_BASE_RANGE": "基本合理", "ABOVE_BASE_RANGE": "偏贵", "ABOVE_OPTIMISTIC_RANGE": "明显偏贵", } class LedgerError(RuntimeError): def __init__(self, code: str, message: str): self.code = code super().__init__(message) @dataclass(frozen=True) class ValuationCandidate: ticker: str company: str market: str currency: str valuation_date: str method: str pessimistic_low: float pessimistic_high: float base_low: float base_high: float optimistic_low: float optimistic_high: float normalized_profit: float | None normalized_pe: float | None pb: float | None ps: float | None consensus_year: int | None consensus_profit: float | None consensus_count: int | None report_path: str snapshot_path: str source_hash: str priority: int = 1 @property def valuation_id(self) -> str: raw = f"{self.ticker}|{self.valuation_date}|{self.source_hash}".encode("utf-8") return "VAL-" + hashlib.sha256(raw).hexdigest()[:24] @dataclass(frozen=True) class PriceRecord: ticker: str trade_date: str close: float currency: str source_id: str source_timestamp: str is_complete_trading_day: bool = True def utc_now() -> str: return datetime.now(timezone.utc).isoformat() def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def atomic_write(path: Path, data: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True) descriptor, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temp = Path(raw_temp) try: with os.fdopen(descriptor, "wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(temp, path) finally: if temp.exists(): temp.unlink() def _load_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise LedgerError("E_INPUT_JSON", f"无法读取 JSON:{path}: {exc}") from exc if not isinstance(value, dict): raise LedgerError("E_INPUT_SCHEMA", f"JSON 根节点必须为对象:{path}") return value def _finite_float(value: Any, field: str, *, optional: bool = False) -> float | None: if value is None and optional: return None try: number = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError) as exc: raise LedgerError("E_INPUT_SCHEMA", f"{field} 必须是数字") from exc if not number.is_finite(): raise LedgerError("E_INPUT_SCHEMA", f"{field} 必须是有限数字") return float(number) def _iso_date(value: Any, field: str) -> str: if not isinstance(value, str): raise LedgerError("E_INPUT_SCHEMA", f"{field} 必须是 YYYY-MM-DD") try: return date.fromisoformat(value[:10]).isoformat() except ValueError as exc: raise LedgerError("E_INPUT_SCHEMA", f"{field} 不是有效日期:{value}") from exc def _canonical_timestamp(value: Any, field: str) -> str: if not isinstance(value, str): raise LedgerError("E_INPUT_SCHEMA", f"{field} 必须是带时区 ISO-8601") try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: raise LedgerError("E_INPUT_SCHEMA", f"{field} 不是有效时间:{value}") from exc if parsed.tzinfo is None: raise LedgerError("E_INPUT_SCHEMA", f"{field} 必须带时区") return parsed.isoformat() def _relative(path: Path, project_root: Path) -> str: try: return path.resolve(strict=True).relative_to(project_root.resolve(strict=True)).as_posix() except (OSError, ValueError) as exc: raise LedgerError("E_PATH_SCOPE", f"正式文件不在项目根目录内:{path}") from exc def _scenario(results: dict[str, Any], role: str) -> dict[str, Any]: rows = results.get("scenarios") if not isinstance(rows, list): raise LedgerError("E_INPUT_SCHEMA", "valuation_results.scenarios 必须是数组") matches = [row for row in rows if isinstance(row, dict) and row.get("role") == role] if len(matches) != 1: raise LedgerError("E_INPUT_SCHEMA", f"估值结果必须且只能有一个 {role} 情景") return matches[0] def _normalized_market(ticker: str, raw_market: Any) -> str: if not isinstance(raw_market, str) or not raw_market.strip(): raise LedgerError("E_INPUT_SCHEMA", "meta.market 缺失") suffixes = { ".SH": "上交所", ".SZ": "深交所", ".BJ": "北交所", ".HK": "港交所", } for suffix, market in suffixes.items(): if ticker.endswith(suffix): return market raise LedgerError("E_INPUT_SCHEMA", f"证券代码市场后缀不受支持:{ticker}") def _candidate_from_package( snapshot_path: Path, results_path: Path, report_path: Path, project_root: Path, *, priority: int, batch_row: dict[str, Any] | None = None, ) -> ValuationCandidate: snapshot = _load_json(snapshot_path) results = _load_json(results_path) snapshot_meta = snapshot.get("meta") result_meta = results.get("meta") if not isinstance(snapshot_meta, dict) or not isinstance(result_meta, dict): raise LedgerError("E_INPUT_SCHEMA", "快照和计算结果都必须包含 meta") fields = ("code", "company", "as_of_date", "currency") for field in fields: if snapshot_meta.get(field) != result_meta.get(field): raise LedgerError("E_INPUT_MISMATCH", f"snapshot/results meta.{field} 不一致") ticker = str(result_meta.get("code") or "").upper() if "." not in ticker: raise LedgerError("E_INPUT_SCHEMA", f"证券代码缺市场后缀:{ticker}") currency = str(result_meta.get("currency") or "") if currency not in {"CNY", "HKD"}: raise LedgerError("E_INPUT_SCHEMA", f"不支持的币种:{currency}") pessimistic = _scenario(results, "pessimistic") base = _scenario(results, "base") optimistic = _scenario(results, "optimistic") ranges = { "pessimistic_low": _finite_float(pessimistic.get("price_low"), "pessimistic.price_low"), "pessimistic_high": _finite_float(pessimistic.get("price_high"), "pessimistic.price_high"), "base_low": _finite_float(base.get("price_low"), "base.price_low"), "base_high": _finite_float(base.get("price_high"), "base.price_high"), "optimistic_low": _finite_float(optimistic.get("price_low"), "optimistic.price_low"), "optimistic_high": _finite_float(optimistic.get("price_high"), "optimistic.price_high"), } if ranges["pessimistic_low"] > ranges["pessimistic_high"]: raise LedgerError("E_RANGE", "悲观区间上下沿反转") if ranges["base_low"] > ranges["base_high"]: raise LedgerError("E_RANGE", "基准区间上下沿反转") if ranges["optimistic_low"] > ranges["optimistic_high"]: raise LedgerError("E_RANGE", "乐观区间上下沿反转") if ranges["base_high"] > ranges["optimistic_high"]: raise LedgerError("E_RANGE", "基准上沿不得高于乐观上沿") metrics = results.get("metrics") if isinstance(results.get("metrics"), dict) else {} institution_summary = ( results.get("institutions", {}).get("summary", {}) if isinstance(results.get("institutions"), dict) else {} ) consensus_year: int | None = None consensus_profit: float | None = None consensus_count: int | None = None numeric_years = sorted( int(year) for year in institution_summary if str(year).isdigit() ) if numeric_years: consensus_year = numeric_years[0] summary = institution_summary.get(str(consensus_year), {}) if isinstance(summary, dict): consensus_profit = _finite_float(summary.get("profit_mean"), "institutions.profit_mean", optional=True) raw_count = summary.get("institution_count") consensus_count = int(raw_count) if raw_count is not None else None analysis = snapshot.get("analysis") if isinstance(snapshot.get("analysis"), dict) else {} if analysis.get("consensus_org_count") is not None: consensus_count = int(analysis["consensus_org_count"]) if batch_row: if batch_row.get("consensus_2026") is not None: consensus_year = 2026 consensus_profit = _finite_float(batch_row["consensus_2026"], "batch.consensus_2026") if batch_row.get("consensus_count") is not None: consensus_count = int(batch_row["consensus_count"]) method = str(base.get("method") or "").upper() if method not in {"PE", "PB", "PS"}: raise LedgerError("E_INPUT_SCHEMA", f"不支持的基准估值方法:{method}") return ValuationCandidate( ticker=ticker, company=str(result_meta.get("company") or ""), market=_normalized_market(ticker, result_meta.get("market")), currency=currency, valuation_date=_iso_date(result_meta.get("as_of_date"), "meta.as_of_date"), method=method, normalized_profit=_finite_float(metrics.get("normalized_profit"), "metrics.normalized_profit", optional=True), normalized_pe=_finite_float(metrics.get("normalized_pe"), "metrics.normalized_pe", optional=True), pb=_finite_float(metrics.get("pb"), "metrics.pb", optional=True), ps=_finite_float(metrics.get("ps"), "metrics.ps", optional=True), consensus_year=consensus_year, consensus_profit=consensus_profit, consensus_count=consensus_count, report_path=_relative(report_path, project_root), snapshot_path=_relative(snapshot_path, project_root), source_hash=sha256_file(snapshot_path), priority=priority, **ranges, ) def _resolve_reference(batch_path: Path, relative: str, project_root: Path) -> Path: raw = Path(relative) if raw.is_absolute() or ".." in raw.parts: raise LedgerError("E_PATH_SCOPE", f"批次引用必须是安全相对路径:{relative}") project = project_root.resolve(strict=True) for parent in (batch_path.parent, *batch_path.parents): try: parent.resolve(strict=True).relative_to(project) except (OSError, ValueError): continue candidate = parent / raw if candidate.is_file(): return candidate if parent.resolve() == project: break raise LedgerError("E_INPUT_MISSING", f"批次正式报告不存在:{relative}") def _candidate_from_batch_row( batch_path: Path, row: dict[str, Any], project_root: Path ) -> ValuationCandidate: formal_path = row.get("formal_path") if not isinstance(formal_path, str) or not formal_path: raise LedgerError("E_INPUT_SCHEMA", "batch row 缺少 formal_path") report = _resolve_reference(batch_path, formal_path, project_root) snapshots = sorted(report.parent.glob("*估值快照_*.json")) results = report.parent / "calculation" / "valuation_results.json" if len(snapshots) != 1 or not results.is_file(): raise LedgerError("E_INPUT_MISSING", f"正式包缺少唯一快照或 calculation:{report.parent}") candidate = _candidate_from_package( snapshots[0], results, report, project_root, priority=3, batch_row=row ) if row.get("ticker") != candidate.ticker or row.get("company") != candidate.company: raise LedgerError("E_INPUT_MISMATCH", f"批次索引与正式包身份不一致:{formal_path}") return candidate def discover_valuations( results_root: Path, project_root: Path ) -> tuple[list[ValuationCandidate], list[dict[str, str]]]: if not results_root.is_dir(): raise LedgerError("E_INPUT_MISSING", f"估值结果目录不存在:{results_root}") candidates: list[ValuationCandidate] = [] gaps: list[dict[str, str]] = [] for batch_path in sorted(results_root.rglob("batch_results_83.json")): try: payload = _load_json(batch_path) rows = payload.get("rows") if not isinstance(rows, list): raise LedgerError("E_INPUT_SCHEMA", "batch_results_83.rows 必须是数组") for index, row in enumerate(rows): try: if not isinstance(row, dict): raise LedgerError("E_INPUT_SCHEMA", "batch row 必须是对象") candidates.append(_candidate_from_batch_row(batch_path, row, project_root)) except LedgerError as exc: gaps.append({"path": f"{batch_path}#rows[{index}]", "code": exc.code, "message": str(exc)}) except LedgerError as exc: gaps.append({"path": str(batch_path), "code": exc.code, "message": str(exc)}) for snapshot_path in sorted(results_root.rglob("*估值快照_*.json")): results_path = snapshot_path.parent / "calculation" / "valuation_results.json" reports = sorted(snapshot_path.parent.glob("*价格合理性评估_*.md")) try: if not results_path.is_file() or len(reports) != 1: raise LedgerError("E_INPUT_MISSING", "正式包缺少 calculation/valuation_results.json 或唯一正式报告") candidates.append( _candidate_from_package( snapshot_path, results_path, reports[0], project_root, priority=1 ) ) except LedgerError as exc: gaps.append({"path": str(snapshot_path), "code": exc.code, "message": str(exc)}) by_date: dict[tuple[str, str], list[ValuationCandidate]] = {} for candidate in candidates: by_date.setdefault((candidate.ticker, candidate.valuation_date), []).append(candidate) selected: list[ValuationCandidate] = [] for key, rows in sorted(by_date.items()): unique = {row.source_hash: row for row in rows} winner = max(unique.values(), key=lambda row: (row.priority, row.snapshot_path, row.source_hash)) selected.append(winner) for source_hash, row in unique.items(): if source_hash != winner.source_hash: gaps.append( { "path": row.snapshot_path, "code": "DUPLICATE_VALUATION_DATE_SKIPPED", "message": f"{key[0]} {key[1]} 已选择更高优先级正式版本 {winner.snapshot_path}", } ) if not selected: raise LedgerError("E_NO_VALUATIONS", "没有发现可结构化导入的正式估值版本") return sorted(selected, key=lambda row: (row.ticker, row.valuation_date)), gaps EXPECTED_TABLE_COLUMNS = { "security": {"ticker", "company", "market", "currency", "active", "created_at", "updated_at"}, "valuation_version": { "valuation_id", "ticker", "valuation_date", "method", "pessimistic_low", "pessimistic_high", "base_low", "base_high", "optimistic_low", "optimistic_high", "normalized_profit", "normalized_pe", "pb", "ps", "consensus_year", "consensus_profit", "consensus_count", "report_path", "snapshot_path", "source_hash", "active_from", "active_to", "created_at", }, "daily_price": { "ticker", "trade_date", "close", "currency", "source_id", "source_timestamp", "ingested_at", }, "daily_judgement": { "ticker", "trade_date", "valuation_id", "close", "base_low", "base_high", "optimistic_high", "label", "distance_to_base_low", "distance_to_base_high", "computed_at", }, } def connect_database(settings: MySQLSettings): try: return connect_target(settings) except mysql.connector.Error as exc: raise LedgerError("E_DATABASE", safe_mysql_error(exc)) from exc def _schema_statements() -> list[str]: schema = Path(__file__).with_name("schema.sql").read_text(encoding="utf-8") return [part.strip() for part in schema.split(";") if part.strip()] def _validate_schema(connection, database: str) -> None: cursor = connection.cursor(dictionary=True) try: cursor.execute( "SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES " "WHERE TABLE_SCHEMA=%s AND TABLE_TYPE='BASE TABLE'", (database,), ) tables = {row["TABLE_NAME"]: row["ENGINE"] for row in cursor.fetchall()} if set(tables) != set(EXPECTED_TABLE_COLUMNS): raise LedgerError("E_SCHEMA", f"业务表集合不正确:{sorted(tables)}") if any(engine != "InnoDB" for engine in tables.values()): raise LedgerError("E_SCHEMA", "四张业务表必须全部使用 InnoDB") cursor.execute( "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA=%s", (database,), ) actual = {name: set() for name in EXPECTED_TABLE_COLUMNS} for row in cursor.fetchall(): if row["TABLE_NAME"] in actual: actual[row["TABLE_NAME"]].add(row["COLUMN_NAME"]) if actual != EXPECTED_TABLE_COLUMNS: raise LedgerError("E_SCHEMA", "业务表字段合同与 schema.sql 不一致") finally: cursor.close() def initialize_database(settings: MySQLSettings) -> None: connection = connect_database(settings) cursor = connection.cursor() try: for statement in _schema_statements(): cursor.execute(statement) connection.commit() _validate_schema(connection, settings.database) except mysql.connector.Error as exc: connection.rollback() raise LedgerError("E_DATABASE", safe_mysql_error(exc)) from exc except BaseException: connection.rollback() raise finally: cursor.close() connection.close() def _write_gaps(path: Path, gaps: Sequence[dict[str, str]]) -> None: buffer = io.StringIO(newline="") writer = csv.DictWriter(buffer, fieldnames=["path", "code", "message"], lineterminator="\n") writer.writeheader() writer.writerows(gaps) atomic_write(path, buffer.getvalue().encode("utf-8")) def import_valuations( settings: MySQLSettings, results_root: Path, project_root: Path, gaps_path: Path, ) -> dict[str, Any]: initialize_database(settings) candidates, gaps = discover_valuations(results_root, project_root) connection = connect_database(settings) cursor = connection.cursor(dictionary=True) inserted = 0 unchanged = 0 affected: set[str] = set() now = datetime.now(timezone.utc).replace(tzinfo=None) latest_security: dict[str, ValuationCandidate] = {} for candidate in candidates: current = latest_security.get(candidate.ticker) if current is None or ( candidate.valuation_date, candidate.priority, candidate.source_hash ) > (current.valuation_date, current.priority, current.source_hash): latest_security[candidate.ticker] = candidate try: connection.start_transaction() for row in candidates: cursor.execute( "SELECT company, market, currency FROM security WHERE ticker=%s", (row.ticker,), ) existing_security = cursor.fetchone() security_row = latest_security[row.ticker] if existing_security and ( existing_security["market"], existing_security["currency"] ) != (security_row.market, security_row.currency): gaps.append( { "path": row.snapshot_path, "code": "E_SECURITY_MISMATCH", "message": f"{row.ticker} 证券身份与数据库不一致", } ) continue if existing_security is None: cursor.execute( "INSERT INTO security(ticker, company, market, currency, active, created_at, updated_at) " "VALUES (%s, %s, %s, %s, 1, %s, %s)", ( security_row.ticker, security_row.company, security_row.market, security_row.currency, now, now, ), ) elif existing_security["company"] != security_row.company: cursor.execute( "UPDATE security SET company=%s, updated_at=%s WHERE ticker=%s", (security_row.company, now, security_row.ticker), ) cursor.execute( "SELECT * FROM valuation_version WHERE ticker=%s AND valuation_date=%s", (row.ticker, row.valuation_date), ) same_date = cursor.fetchall() if same_date and all(item["source_hash"] != row.source_hash for item in same_date): gaps.append( { "path": row.snapshot_path, "code": "E_DUPLICATE_VALUATION_DATE", "message": f"{row.ticker} {row.valuation_date} 已有不同正式版本,拒绝产生重叠有效期", } ) continue matching = next( (item for item in same_date if item["source_hash"] == row.source_hash), None, ) if matching is not None: expected = { "valuation_id": row.valuation_id, "ticker": row.ticker, "valuation_date": row.valuation_date, "method": row.method, "pessimistic_low": row.pessimistic_low, "pessimistic_high": row.pessimistic_high, "base_low": row.base_low, "base_high": row.base_high, "optimistic_low": row.optimistic_low, "optimistic_high": row.optimistic_high, "normalized_profit": row.normalized_profit, "normalized_pe": row.normalized_pe, "pb": row.pb, "ps": row.ps, "consensus_year": row.consensus_year, "consensus_profit": row.consensus_profit, "consensus_count": row.consensus_count, "report_path": row.report_path, "snapshot_path": row.snapshot_path, "source_hash": row.source_hash, } mismatches = [ name for name, value in expected.items() if not _immutable_equal(matching.get(name), value) ] if mismatches: gaps.append( { "path": row.snapshot_path, "code": "E_IMMUTABLE_VERSION_CONFLICT", "message": "同一估值身份的不可变字段冲突:" + ",".join(mismatches), } ) else: unchanged += 1 continue values = ( row.valuation_id, row.ticker, row.valuation_date, row.method, row.pessimistic_low, row.pessimistic_high, row.base_low, row.base_high, row.optimistic_low, row.optimistic_high, row.normalized_profit, row.normalized_pe, row.pb, row.ps, row.consensus_year, row.consensus_profit, row.consensus_count, row.report_path, row.snapshot_path, row.source_hash, row.valuation_date, now, ) cursor.execute( """ INSERT INTO valuation_version( valuation_id, ticker, valuation_date, method, pessimistic_low, pessimistic_high, base_low, base_high, optimistic_low, optimistic_high, normalized_profit, normalized_pe, pb, ps, consensus_year, consensus_profit, consensus_count, report_path, snapshot_path, source_hash, active_from, active_to, created_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s) """, values, ) inserted += 1 affected.add(row.ticker) for ticker in affected: cursor.execute( "SELECT valuation_id, valuation_date FROM valuation_version " "WHERE ticker=%s ORDER BY valuation_date, valuation_id", (ticker,), ) versions = cursor.fetchall() for index, version in enumerate(versions): valuation_date = _date_value(version["valuation_date"]) active_from = valuation_date if index == 0 else valuation_date + timedelta(days=1) active_to = None if index + 1 < len(versions): active_to = _date_value(versions[index + 1]["valuation_date"]) cursor.execute( "UPDATE valuation_version SET active_from=%s, active_to=%s " "WHERE valuation_id=%s", (active_from, active_to, version["valuation_id"]), ) connection.commit() except mysql.connector.Error as exc: connection.rollback() raise LedgerError("E_DATABASE", safe_mysql_error(exc)) from exc except BaseException: connection.rollback() raise finally: cursor.close() connection.close() _write_gaps(gaps_path, gaps) return { "status": "IMPORTED", "discovered": len(candidates), "inserted": inserted, "unchanged": unchanged, "gap_count": len(gaps), "gaps_path": str(gaps_path), } def _date_value(value: Any) -> date: if isinstance(value, datetime): return value.date() if isinstance(value, date): return value return date.fromisoformat(str(value)[:10]) def _immutable_equal(actual: Any, expected: Any) -> bool: if isinstance(actual, (date, datetime)): actual = actual.date().isoformat() if isinstance(actual, datetime) else actual.isoformat() if expected is None or actual is None: return actual is None and expected is None if isinstance(actual, (Decimal, float, int)) and isinstance(expected, (float, int, Decimal)): return math.isclose(float(actual), float(expected), rel_tol=0, abs_tol=5.1e-7) return actual == expected def v1_price_position(close: float, base_low: float, base_high: float, optimistic_high: float) -> str: values = (close, base_low, base_high, optimistic_high) if not all(math.isfinite(value) for value in values): raise LedgerError("E_RANGE", "价格和区间必须是有限数字") if close <= 0 or base_low <= 0 or base_low > base_high or base_high > optimistic_high: raise LedgerError("E_RANGE", "价格必须为正且 base_low <= base_high <= optimistic_high") if close > optimistic_high: return "ABOVE_OPTIMISTIC_RANGE" if close > base_high: return "ABOVE_BASE_RANGE" if close >= base_low: return "WITHIN_BASE_RANGE" return "BELOW_BASE_RANGE" def judge_price(close: float, base_low: float, base_high: float, optimistic_high: float) -> str: return V1_POSITION_LABELS[v1_price_position(close, base_low, base_high, optimistic_high)] def load_price_fixture(path: Path) -> list[PriceRecord]: payload = _load_json(path) if payload.get("schema_version") != 1 or not isinstance(payload.get("prices"), list): raise LedgerError("E_INPUT_SCHEMA", "价格 fixture 必须是 schema_version=1 且包含 prices 数组") records: list[PriceRecord] = [] seen: set[str] = set() for index, item in enumerate(payload["prices"]): if not isinstance(item, dict): raise LedgerError("E_INPUT_SCHEMA", f"prices[{index}] 必须是对象") ticker = str(item.get("ticker") or "").upper() if not ticker or ticker in seen: raise LedgerError("E_INPUT_SCHEMA", f"prices[{index}].ticker 缺失或重复") seen.add(ticker) complete = item.get("is_complete_trading_day") is True records.append( PriceRecord( ticker=ticker, trade_date=_iso_date(item.get("trade_date"), f"prices[{index}].trade_date"), close=float(_finite_float(item.get("close"), f"prices[{index}].close")), currency=str(item.get("currency") or ""), source_id=str(item.get("source_id") or ""), source_timestamp=_canonical_timestamp(item.get("source_timestamp"), f"prices[{index}].source_timestamp"), is_complete_trading_day=complete, ) ) return records def _serialise_row(row: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {} for key, value in row.items(): if isinstance(value, Decimal): result[key] = float(value) elif isinstance(value, datetime): result[key] = value.isoformat() elif isinstance(value, date): result[key] = value.isoformat() else: result[key] = value return result def active_securities(settings: MySQLSettings) -> list[dict[str, str]]: connection = connect_database(settings) cursor = connection.cursor(dictionary=True) try: cursor.execute( "SELECT ticker, company, market, currency FROM security " "WHERE active=1 ORDER BY ticker" ) return [_serialise_row(row) for row in cursor.fetchall()] finally: cursor.close() connection.close() def apply_daily_prices( settings: MySQLSettings, as_of: str, prices: Iterable[PriceRecord], initial_failures: Sequence[dict[str, str]] = (), ) -> dict[str, Any]: cutoff = date.fromisoformat(_iso_date(as_of, "as_of")) connection = connect_database(settings) cursor = connection.cursor(dictionary=True) failures = [dict(item) for item in initial_failures] inserted_prices = 0 inserted_judgements = 0 unchanged = 0 now = datetime.now(timezone.utc).replace(tzinfo=None) try: connection.start_transaction() cursor.execute("SELECT ticker, currency FROM security WHERE active=1") securities = {row["ticker"]: row for row in cursor.fetchall()} for price in prices: try: security = securities.get(price.ticker) if security is None: raise LedgerError("E_UNKNOWN_TICKER", "证券未登记或未启用") if not price.is_complete_trading_day: raise LedgerError("E_INCOMPLETE_TRADING_DAY", "不是完整交易日收盘价") if not math.isfinite(price.close) or price.close <= 0: raise LedgerError("E_PRICE", "收盘价必须是有限正数") trade_date = date.fromisoformat(price.trade_date) if trade_date > cutoff: raise LedgerError("E_ASOF", "行情日期晚于请求 as-of") if price.currency != security["currency"]: raise LedgerError("E_CURRENCY", "行情币种与证券币种不一致") if not price.source_id: raise LedgerError("E_SOURCE", "行情 source_id 缺失") source_timestamp = _canonical_timestamp(price.source_timestamp, "source_timestamp") source_instant = datetime.fromisoformat(source_timestamp).astimezone(timezone.utc) cursor.execute( """ SELECT * FROM valuation_version WHERE ticker=%s AND active_from<=%s AND (active_to IS NULL OR active_to>=%s) ORDER BY valuation_date DESC """, (price.ticker, price.trade_date, price.trade_date), ) versions = cursor.fetchall() if len(versions) != 1: raise LedgerError("E_VALUATION_VERSION", f"有效估值版本数量必须为 1,实际 {len(versions)}") version = versions[0] base_low = float(version["base_low"]) base_high = float(version["base_high"]) optimistic_high = float(version["optimistic_high"]) label = judge_price(price.close, base_low, base_high, optimistic_high) expected = ( version["valuation_id"], price.close, base_low, base_high, optimistic_high, label, ) cursor.execute( "SELECT close, currency, source_id, source_timestamp FROM daily_price " "WHERE ticker=%s AND trade_date=%s", (price.ticker, price.trade_date), ) existing_price = cursor.fetchone() if existing_price: timestamp_db = source_instant.replace(tzinfo=None) if ( not math.isclose(float(existing_price["close"]), price.close, rel_tol=0, abs_tol=5.1e-7) or existing_price["currency"] != price.currency or existing_price["source_id"] != price.source_id or existing_price["source_timestamp"] != timestamp_db ): raise LedgerError("E_PRICE_IMMUTABLE", "同交易日历史收盘价冲突,拒绝改写") else: cursor.execute( """ INSERT INTO daily_price(ticker, trade_date, close, currency, source_id, source_timestamp, ingested_at) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( price.ticker, price.trade_date, price.close, price.currency, price.source_id, source_instant.replace(tzinfo=None), now, ), ) inserted_prices += 1 cursor.execute( """ SELECT valuation_id, close, base_low, base_high, optimistic_high, label FROM daily_judgement WHERE ticker=%s AND trade_date=%s """, (price.ticker, price.trade_date), ) existing_judgement = cursor.fetchone() if existing_judgement: actual = ( existing_judgement["valuation_id"], float(existing_judgement["close"]), float(existing_judgement["base_low"]), float(existing_judgement["base_high"]), float(existing_judgement["optimistic_high"]), existing_judgement["label"], ) if actual[0] != expected[0] or actual[5] != expected[5] or any( not math.isclose(actual[index], expected[index], rel_tol=0, abs_tol=5.1e-7) for index in range(1, 5) ): raise LedgerError("E_JUDGEMENT_IMMUTABLE", "历史判定与当前计算不一致,拒绝改写") unchanged += 1 continue cursor.execute( """ INSERT INTO daily_judgement( ticker, trade_date, valuation_id, close, base_low, base_high, optimistic_high, label, distance_to_base_low, distance_to_base_high, computed_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( price.ticker, price.trade_date, version["valuation_id"], price.close, base_low, base_high, optimistic_high, label, (price.close - base_low) / base_low, (price.close - base_high) / base_high, now, ), ) inserted_judgements += 1 except LedgerError as exc: failures.append({"ticker": price.ticker, "code": exc.code, "message": str(exc)}) connection.commit() except mysql.connector.Error as exc: connection.rollback() raise LedgerError("E_DATABASE", safe_mysql_error(exc)) from exc except BaseException: connection.rollback() raise finally: cursor.close() connection.close() if failures and inserted_judgements == 0: status = "FAILED" elif failures: status = "COMPLETE_WITH_GAPS" elif inserted_prices == 0 and inserted_judgements == 0: status = "NO_NEW_TRADING_DAY" else: status = "COMPLETE" return { "status": status, "as_of": cutoff.isoformat(), "inserted_prices": inserted_prices, "inserted_judgements": inserted_judgements, "unchanged": unchanged, "failure_count": len(failures), "failures": failures, } LATEST_QUERY = """ WITH chosen AS ( SELECT ticker, MAX(trade_date) AS trade_date FROM daily_judgement GROUP BY ticker ) SELECT d.ticker, s.company, s.market, s.currency, d.close, d.trade_date, d.base_low, d.base_high, d.optimistic_high, d.label, d.distance_to_base_low, d.distance_to_base_high, v.valuation_date, v.consensus_year, v.consensus_profit, v.consensus_count, v.report_path, v.snapshot_path, v.source_hash FROM chosen c JOIN daily_judgement d ON d.ticker=c.ticker AND d.trade_date=c.trade_date JOIN security s ON s.ticker=d.ticker JOIN valuation_version v ON v.valuation_id=d.valuation_id """ def list_rows( settings: MySQLSettings, selected_date: str = "latest", *, label: str | None = None, ticker: str | None = None, company: str | None = None, ) -> list[dict[str, Any]]: connection = connect_database(settings) cursor = connection.cursor(dictionary=True) try: if selected_date == "latest": query = LATEST_QUERY params: list[Any] = [] else: selected_date = _iso_date(selected_date, "date") query = LATEST_QUERY.replace( "SELECT ticker, MAX(trade_date) AS trade_date\n FROM daily_judgement\n GROUP BY ticker", "SELECT ticker, trade_date FROM daily_judgement WHERE trade_date=%s", ) params = [selected_date] filters: list[str] = [] if label: filters.append("d.label=%s") params.append(label) if ticker: filters.append("d.ticker=%s") params.append(ticker.upper()) if company: filters.append("s.company LIKE %s") params.append(f"%{company}%") if filters: query += " WHERE " + " AND ".join(filters) query += " ORDER BY d.ticker" cursor.execute(query, params) return [_serialise_row(row) for row in cursor.fetchall()] finally: cursor.close() connection.close() def show_ticker(settings: MySQLSettings, ticker: str) -> dict[str, Any]: ticker = ticker.upper() connection = connect_database(settings) cursor = connection.cursor(dictionary=True) try: cursor.execute("SELECT * FROM security WHERE ticker=%s", (ticker,)) security = cursor.fetchone() if security is None: raise LedgerError("E_UNKNOWN_TICKER", f"证券不存在:{ticker}") cursor.execute("SELECT * FROM valuation_version WHERE ticker=%s ORDER BY valuation_date", (ticker,)) versions = [_serialise_row(row) for row in cursor.fetchall()] cursor.execute("SELECT * FROM daily_price WHERE ticker=%s ORDER BY trade_date", (ticker,)) prices = [_serialise_row(row) for row in cursor.fetchall()] cursor.execute("SELECT * FROM daily_judgement WHERE ticker=%s ORDER BY trade_date", (ticker,)) judgements = [_serialise_row(row) for row in cursor.fetchall()] return { "security": _serialise_row(security), "valuation_versions": versions, "daily_prices": prices, "daily_judgements": judgements, } finally: cursor.close() connection.close() EXPORT_FIELDS = [ "ticker", "company", "market", "currency", "close", "trade_date", "base_low", "base_high", "optimistic_high", "label", "distance_to_base_low", "distance_to_base_high", "valuation_date", "consensus_year", "consensus_profit", "consensus_count", "report_path", ] def _write_pair(files: Sequence[tuple[Path, bytes]]) -> list[str]: if len(files) != 2 or len({path for path, _ in files}) != 2: raise LedgerError("E_EXPORT", "latest 导出必须是两个不同文件") stages: dict[Path, Path] = {} backups: dict[Path, Path] = {} placed: set[Path] = set() rollback_errors: list[BaseException] = [] try: for target, data in files: target.parent.mkdir(parents=True, exist_ok=True) if target.exists() and not target.is_file(): raise LedgerError("E_EXPORT_PATH", f"导出目标不是普通文件:{target}") descriptor, raw_stage = tempfile.mkstemp(prefix=".pair-stage-", dir=target.parent) stage = Path(raw_stage) stages[target] = stage try: handle = os.fdopen(descriptor, "wb") except BaseException as primary: try: os.close(descriptor) except BaseException as secondary: if hasattr(primary, "add_note"): primary.add_note(f"latest staging fd 回收异常:{type(secondary).__name__}") raise with handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) for target, _ in files: if target.exists(): descriptor, raw_backup = tempfile.mkstemp(prefix=".pair-backup-", dir=target.parent) os.close(descriptor) backup = Path(raw_backup) backup.unlink() os.replace(target, backup) backups[target] = backup for target, _ in files: os.replace(stages[target], target) placed.add(target) except BaseException as primary: for target, _ in reversed(files): try: if target in placed and target.exists(): target.unlink() backup = backups.get(target) if backup is not None and backup.exists(): os.replace(backup, target) except BaseException as secondary: rollback_errors.append(secondary) for stage in stages.values(): try: if stage.exists(): stage.unlink() except BaseException as secondary: rollback_errors.append(secondary) for secondary in rollback_errors: if hasattr(primary, "add_note"): primary.add_note(f"latest 双文件回滚异常:{type(secondary).__name__}") raise warnings: list[str] = [] for backup in backups.values(): try: if backup.exists(): backup.unlink() except OSError: warnings.append("E_EXPORT_BACKUP_RETAINED") for stage in stages.values(): if stage.exists(): stage.unlink() return warnings def export_latest(settings: MySQLSettings, output_dir: Path, selected_date: str = "latest") -> dict[str, Any]: rows = list_rows(settings, selected_date) output_dir.mkdir(parents=True, exist_ok=True) csv_buffer = io.StringIO(newline="") writer = csv.DictWriter(csv_buffer, fieldnames=EXPORT_FIELDS, extrasaction="ignore", lineterminator="\n") writer.writeheader() writer.writerows(rows) csv_path = output_dir / "latest.csv" lines = [ "# 股票估值每日台账最新总表", "", f"- 生成时间:`{utc_now()}`", f"- 记录数:`{len(rows)}`", "- 口径:最近完整交易日收盘价相对最近有效正式估值版本;不构成交易指令。", "", "| 代码 | 公司 | 市场 | 币种 | 收盘价 | 交易日 | 基准区间 | 乐观上沿 | 判定 | 估值日 | 距基准下沿 | 距基准上沿 | 机构覆盖 | 正式报告 |", "|---|---|---|---|---:|---|---:|---:|---|---|---:|---:|---:|---|", ] for row in rows: coverage = "-" if row["consensus_count"] is None else str(row["consensus_count"]) lines.append( "| {ticker} | {company} | {market} | {currency} | {close:.4f} | {trade_date} | " "{base_low:.4f}—{base_high:.4f} | {optimistic_high:.4f} | {label} | {valuation_date} | " "{distance_to_base_low:.2%} | {distance_to_base_high:.2%} | {coverage} | `{report_path}` |".format( coverage=coverage, **row ) ) markdown_path = output_dir / "latest.md" warnings = _write_pair( ( (csv_path, csv_buffer.getvalue().encode("utf-8-sig")), (markdown_path, ("\n".join(lines) + "\n").encode("utf-8")), ) ) return { "status": "EXPORTED", "row_count": len(rows), "markdown": str(markdown_path), "csv": str(csv_path), "warnings": warnings, }