"""期权业务时区墙钟:库内统一按 APP_TIMEZONE(默认 Asia/Shanghai)存储,不可当 UTC.""" from __future__ import annotations import os import sqlite3 from datetime import datetime, timezone from typing import Any, Optional from zoneinfo import ZoneInfo _APP_TZ = ZoneInfo( (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" ) _MIGRATE_FLAG = "options_ts_utc_wall_to_app_done" _SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done" def options_app_tz() -> ZoneInfo: return _APP_TZ def options_app_now_str() -> str: return datetime.now(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S") def ms_to_options_app_str(ms: Any) -> Optional[str]: """交易所 ms(UTC 绝对时间) → 业务时区墙钟字符串.""" try: val = float(ms) except (TypeError, ValueError): return None if val <= 0: return None try: dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone(_APP_TZ) return dt.strftime("%Y-%m-%d %H:%M:%S") except (TypeError, ValueError, OSError, OverflowError): return None def options_wall_clock_to_ms(raw: Any) -> Optional[int]: """业务时区墙钟 → UTC ms(与合约复盘 _local_input_datetime_to_ms 同口径).""" if raw is None or raw == "": return None s = str(raw).strip().replace("T", " ") for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)): try: dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ) return int(dt.timestamp() * 1000) except ValueError: continue return None def utc_wall_clock_to_app_str(raw: Any) -> Optional[str]: """把误存成 UTC 墙钟的字符串改写为业务时区墙钟(+8 等).""" if raw is None or raw == "": return None s = str(raw).strip().replace("T", " ") for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)): try: dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=timezone.utc) return dt.astimezone(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S") except ValueError: continue return None def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str, int]: """一次性:历史 UTC 墙钟(SQLite CURRENT_TIMESTAMP / 旧 monitor)改为业务时区. 幂等:同步状态 options_ts_utc_wall_to_app_done=1 后跳过. """ from lib.options.options_review_db import init_options_review_tables from lib.options.options_review_lib import get_sync_state, set_sync_state init_options_review_tables(conn) if get_sync_state(conn, _MIGRATE_FLAG) == "1": return {"skipped": 1, "trades": 0, "review": 0, "hidden": 0} def _shift_table(table: str, cols: tuple[str, ...]) -> int: try: rows = conn.execute( f"SELECT rowid AS _rid, {', '.join(cols)} FROM {table}" ).fetchall() except sqlite3.Error: return 0 n = 0 for row in rows: sets: list[str] = [] vals: list[Any] = [] for col in cols: old = row[col] new = utc_wall_clock_to_app_str(old) if new and str(old or "").strip() != new: sets.append(f"{col}=?") vals.append(new) if not sets: continue vals.append(int(row["_rid"])) conn.execute( f"UPDATE {table} SET {', '.join(sets)} WHERE rowid=?", vals, ) n += 1 return n trades = 0 try: from lib.options.options_db import init_options_tables init_options_tables(conn) trades = _shift_table("options_trades", ("created_at", "closed_at")) except Exception: trades = 0 review = _shift_table("options_review_trades", ("opened_at", "closed_at")) hidden = _shift_table("options_review_hidden", ("closed_at",)) set_sync_state(conn, _MIGRATE_FLAG, "1") return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden} def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]: """修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行. 典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳. 幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修). """ from lib.options.options_review_db import init_options_review_tables from lib.options.options_review_lib import set_sync_state init_options_review_tables(conn) trades_fixed = 0 review_fixed = 0 try: from lib.options.options_db import init_options_tables init_options_tables(conn) rows = conn.execute( """ SELECT id, closed_at, signal_note FROM options_trades WHERE status = 'closed' AND closed_at IS NOT NULL AND TRIM(closed_at) != '' """ ).fetchall() for row in rows: closed = str(row["closed_at"] or "").strip() note = str(row["signal_note"] or "").strip() if options_wall_clock_to_ms(closed) is not None: continue if options_wall_clock_to_ms(note) is None: continue conn.execute( """ UPDATE options_trades SET closed_at = ?, signal_note = ? WHERE id = ? """, (note, closed, int(row["id"])), ) trades_fixed += 1 hk = f"local_opt:{int(row['id'])}" cur = conn.execute( """ UPDATE options_review_trades SET closed_at = ? WHERE history_key = ? AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?) """, (note, hk, closed), ) review_fixed += int(cur.rowcount or 0) except Exception: pass set_sync_state(conn, _SWAP_REPAIR_FLAG, "1") return {"trades": trades_fixed, "review": review_fixed}