migration: preserve market insights slice

This commit is contained in:
leefer
2026-07-31 02:41:56 +08:00
parent 8904209938
commit f511bf484f
21 changed files with 2285 additions and 1753 deletions
+8 -301
View File
@@ -42,7 +42,6 @@ from heaven_engine import (
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, stream_with_mentor from mentor_agent import MentorAgentError, stream_with_mentor
from market_insights import MarketInsightsService
from screener import ( from screener import (
FACTOR_FIELDS, FACTOR_FIELDS,
FACTOR_GROUPS, FACTOR_GROUPS,
@@ -53,10 +52,14 @@ from screener import (
from backend.features.accounts.http import AccountHttpMixin from backend.features.accounts.http import AccountHttpMixin
from backend.features.accounts.security import SecretVault from backend.features.accounts.security import SecretVault
from backend.features.accounts.service import AccountService from backend.features.accounts.service import AccountService
from backend.features.auction import AuctionServiceMixin
from backend.features.dragon_tiger import DragonTigerServiceMixin
from backend.features.pools import PoolServiceMixin from backend.features.pools import PoolServiceMixin
from backend.features.popularity import PopularityServiceMixin
from backend.features.rotation import RotationServiceMixin from backend.features.rotation import RotationServiceMixin
from backend.features.sentiment import SentimentServiceMixin from backend.features.sentiment import SentimentServiceMixin
from backend.features.system import SystemHttpMixin from backend.features.system import SystemHttpMixin
from backend.features.themes import ThemeServiceMixin
from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue
@@ -142,6 +145,10 @@ class DashboardService(
SentimentServiceMixin, SentimentServiceMixin,
PoolServiceMixin, PoolServiceMixin,
RotationServiceMixin, RotationServiceMixin,
AuctionServiceMixin,
ThemeServiceMixin,
PopularityServiceMixin,
DragonTigerServiceMixin,
): ):
def __init__(self) -> None: def __init__(self) -> None:
runtime = load_runtime_settings() runtime = load_runtime_settings()
@@ -753,28 +760,9 @@ class DashboardService(
} }
def _market_insights(self) -> MarketInsightsService:
if not self.configured:
raise ValueError("行情数据尚未配置。")
return MarketInsightsService(
self.database,
self._tushare_client(),
ifind=self.ifind,
)
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().auction_center(
normalize_date(trade_date), force, self.current_user_id
)
def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().theme_library(normalize_date(trade_date), force)
def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]:
return self._market_insights().theme_detail(code, normalize_date(trade_date))
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().popularity(normalize_date(trade_date), force)
@staticmethod @staticmethod
def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
@@ -3074,287 +3062,6 @@ class DashboardService(
) )
return result return result
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
cache_kind = "hot_money_profiles_v1"
cache_key = "directory"
cached = self.database.get_data_snapshot(cache_kind, cache_key)
if cached and not force:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().hot_money_profiles()
except TushareError:
if cached:
cached["meta"] = {
**cached.get("meta", {}),
"cached": True,
"stale": True,
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请稍后重试。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
return payload
if cached:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
cache_kind = "hot_money_detail_v3"
if not force:
cached = self.database.get_data_snapshot(cache_kind, normalized_date)
if (
cached
and cached.get("meta", {}).get("source") == "tushare"
and cached.get("meta", {}).get("status") == "success"
and int(cached.get("meta", {}).get("schema_version") or 0) == 3
):
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().dragon_tiger(normalized_date)
except TushareError as exc:
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "tushare_error",
"status": "error",
"schema_version": 3,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "龙虎榜数据暂不可用,请稍后重试。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
return payload
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "unavailable",
"status": "unavailable",
"schema_version": 3,
"cached": False,
"notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]:
aliases = self.database.list_seat_aliases()
result = dict(payload)
rows = payload.get("rows") or []
for row in rows:
for institution in row.get("institutions") or []:
institution["alias"] = aliases.get(institution.get("seat_name", ""), "")
traders: dict[tuple[str, str], dict[str, Any]] = {}
unclassified: dict[str, dict[str, Any]] = {}
seen_operations: set[tuple[Any, ...]] = set()
builtin_aliases = {
"国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一",
}
for row in rows:
for institution in row.get("institutions") or []:
seat_name = str(institution.get("seat_name") or "未知席位").strip()
saved_alias = str(institution.get("alias") or "").strip()
builtin_alias = builtin_aliases.get(seat_name, "")
if saved_alias or builtin_alias:
identity_name = saved_alias or builtin_alias
identity_type = "trader"
recognized = True
identity_source = "manual" if saved_alias else "builtin"
elif "机构专用" in seat_name:
identity_name = "机构专用"
identity_type = "institution"
recognized = True
identity_source = "system"
elif "沪股通专用" in seat_name or "深股通专用" in seat_name:
identity_name = "北向资金"
identity_type = "channel"
recognized = True
identity_source = "system"
else:
identity_name = seat_name
identity_type = "unclassified"
recognized = False
identity_source = "raw"
buy = round(float(institution.get("buy_million") or 0), 2)
sell = round(float(institution.get("sell_million") or 0), 2)
net_buy = round(float(institution.get("net_buy_million") or 0), 2)
operation_key = (row.get("code"), seat_name, buy, sell, net_buy)
if operation_key in seen_operations:
continue
seen_operations.add(operation_key)
group_key = (identity_type, identity_name)
group = traders.setdefault(
group_key,
{
"name": identity_name,
"identity_type": identity_type,
"identity_source": identity_source,
"recognized": recognized,
"buy_million": 0.0,
"sell_million": 0.0,
"net_buy_million": 0.0,
"seat_names": set(),
"stock_codes": set(),
"operations": [],
},
)
group["buy_million"] += buy
group["sell_million"] += sell
group["net_buy_million"] += net_buy
group["seat_names"].add(seat_name)
group["stock_codes"].add(str(row.get("code") or ""))
group["operations"].append(
{
"code": row.get("code") or "",
"name": row.get("name") or "--",
"change": row.get("change") or 0,
"direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平",
"buy_million": buy,
"sell_million": sell,
"net_buy_million": net_buy,
"reason": row.get("reason") or "--",
"seat_name": seat_name,
"seat_alias": identity_name if recognized else "",
}
)
if not recognized:
pending = unclassified.setdefault(
seat_name,
{
"seat_name": seat_name,
"stock_codes": set(),
"operation_count": 0,
"buy_million": 0.0,
"sell_million": 0.0,
"net_buy_million": 0.0,
},
)
pending["stock_codes"].add(str(row.get("code") or ""))
pending["operation_count"] += 1
pending["buy_million"] += buy
pending["sell_million"] += sell
pending["net_buy_million"] += net_buy
type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3}
aggregated = list(traders.values())
aggregated.sort(
key=lambda item: (
type_order.get(item["identity_type"], 9),
-abs(item["net_buy_million"]),
item["name"],
)
)
for index, group in enumerate(aggregated, start=1):
group["id"] = f"identity-{index}"
group["buy_million"] = round(group["buy_million"], 2)
group["sell_million"] = round(group["sell_million"], 2)
group["net_buy_million"] = round(group["net_buy_million"], 2)
group["seat_count"] = len(group.pop("seat_names"))
group["stock_count"] = len(group.pop("stock_codes"))
group["operation_count"] = len(group["operations"])
group["operations"].sort(
key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True
)
pending_seats = list(unclassified.values())
for pending in pending_seats:
pending["stock_count"] = len(pending.pop("stock_codes"))
pending["buy_million"] = round(pending["buy_million"], 2)
pending["sell_million"] = round(pending["sell_million"], 2)
pending["net_buy_million"] = round(pending["net_buy_million"], 2)
pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True)
operation_count = sum(item["operation_count"] for item in aggregated)
active_stocks = {
operation["code"] for item in aggregated for operation in item["operations"]
}
seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2)
result["rows"] = rows
result["traders"] = aggregated
result["unclassified_seats"] = pending_seats
result["summary"] = {
**(payload.get("summary") or {}),
"trader_count": sum(item["identity_type"] == "trader" for item in aggregated),
"identity_count": len(aggregated),
"operation_count": operation_count,
"active_stock_count": len(active_stocks),
"seat_net_buy_million": seat_net_buy,
"unclassified_count": len(pending_seats),
}
return result
SERVICE = DashboardService() SERVICE = DashboardService()
+4
View File
@@ -0,0 +1,4 @@
from .repository import AuctionRepositoryMixin
from .service import AuctionServiceMixin
__all__ = ["AuctionRepositoryMixin", "AuctionServiceMixin"]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
class AuctionRepositoryMixin:
def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int:
values = []
for row in rows:
trade_date = str(row.get("trade_date") or "")
ts_code = str(row.get("ts_code") or "")
price = float(row.get("price") or 0)
pre_close = float(row.get("pre_close") or 0)
if not trade_date or not ts_code or price <= 0 or pre_close <= 0:
continue
values.append(
(
trade_date,
ts_code,
price,
pre_close,
(price / pre_close - 1) * 100,
float(row.get("vol") or 0),
float(row.get("amount") or 0),
float(row.get("turnover_rate") or 0),
float(row.get("volume_ratio") or 0),
)
)
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO auction_factors
(trade_date, ts_code, price, pre_close, change, vol, amount,
turnover_rate, volume_ratio)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
price=excluded.price, pre_close=excluded.pre_close,
change=excluded.change, vol=excluded.vol, amount=excluded.amount,
turnover_rate=excluded.turnover_rate,
volume_ratio=excluded.volume_ratio
""",
values,
)
return len(values)
def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else ""
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
with self.connect() as connection:
rows = connection.execute(
f"SELECT DISTINCT trade_date FROM auction_factors {where} "
"ORDER BY trade_date DESC LIMIT ?",
parameters,
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code",
(trade_date,),
).fetchall()
return [dict(row) for row in rows]
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.market.insights import MarketInsightsService
class AuctionServiceMixin:
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().auction_center(
normalize_date(trade_date), force, self.current_user_id
)
@@ -0,0 +1,4 @@
from .repository import DragonTigerRepositoryMixin
from .service import DragonTigerServiceMixin
__all__ = ["DragonTigerRepositoryMixin", "DragonTigerServiceMixin"]
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
class DragonTigerRepositoryMixin:
def list_seat_aliases(self) -> dict[str, str]:
with self.connect() as connection:
rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall()
return {row["seat_name"]: row["alias"] for row in rows}
def save_seat_alias(self, seat_name: str, alias: str) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO seat_aliases (seat_name, alias, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(seat_name) DO UPDATE SET
alias = excluded.alias,
updated_at = excluded.updated_at
""",
(seat_name, alias, now),
)
def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int:
grouped: dict[tuple[str, str], dict[str, float | int]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
ts_code = str(row.get("ts_code") or "")
seat_name = str(row.get("exalter") or row.get("seat_name") or "")
if not trade_date or not ts_code or "机构专用" not in seat_name:
continue
group = grouped.setdefault(
(trade_date, ts_code),
{"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0},
)
group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0)
group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0)
group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0)
group["seats"] = int(group["seats"]) + 1
values = [
(trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"])
for (trade_date, ts_code), item in grouped.items()
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO lhb_institution_daily
(trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
net_buy_amount=excluded.net_buy_amount,
buy_amount=excluded.buy_amount,
sell_amount=excluded.sell_amount,
seat_count=excluded.seat_count
""",
values,
)
return len(values)
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.data.providers.tushare_client import TushareError
class DragonTigerServiceMixin:
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
cache_kind = "hot_money_profiles_v1"
cache_key = "directory"
cached = self.database.get_data_snapshot(cache_kind, cache_key)
if cached and not force:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().hot_money_profiles()
except TushareError:
if cached:
cached["meta"] = {
**cached.get("meta", {}),
"cached": True,
"stale": True,
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请稍后重试。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
return payload
if cached:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
cache_kind = "hot_money_detail_v3"
if not force:
cached = self.database.get_data_snapshot(cache_kind, normalized_date)
if (
cached
and cached.get("meta", {}).get("source") == "tushare"
and cached.get("meta", {}).get("status") == "success"
and int(cached.get("meta", {}).get("schema_version") or 0) == 3
):
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().dragon_tiger(normalized_date)
except TushareError as exc:
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "tushare_error",
"status": "error",
"schema_version": 3,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "龙虎榜数据暂不可用,请稍后重试。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
return payload
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "unavailable",
"status": "unavailable",
"schema_version": 3,
"cached": False,
"notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]:
aliases = self.database.list_seat_aliases()
result = dict(payload)
rows = payload.get("rows") or []
for row in rows:
for institution in row.get("institutions") or []:
institution["alias"] = aliases.get(institution.get("seat_name", ""), "")
traders: dict[tuple[str, str], dict[str, Any]] = {}
unclassified: dict[str, dict[str, Any]] = {}
seen_operations: set[tuple[Any, ...]] = set()
builtin_aliases = {
"国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一",
}
for row in rows:
for institution in row.get("institutions") or []:
seat_name = str(institution.get("seat_name") or "未知席位").strip()
saved_alias = str(institution.get("alias") or "").strip()
builtin_alias = builtin_aliases.get(seat_name, "")
if saved_alias or builtin_alias:
identity_name = saved_alias or builtin_alias
identity_type = "trader"
recognized = True
identity_source = "manual" if saved_alias else "builtin"
elif "机构专用" in seat_name:
identity_name = "机构专用"
identity_type = "institution"
recognized = True
identity_source = "system"
elif "沪股通专用" in seat_name or "深股通专用" in seat_name:
identity_name = "北向资金"
identity_type = "channel"
recognized = True
identity_source = "system"
else:
identity_name = seat_name
identity_type = "unclassified"
recognized = False
identity_source = "raw"
buy = round(float(institution.get("buy_million") or 0), 2)
sell = round(float(institution.get("sell_million") or 0), 2)
net_buy = round(float(institution.get("net_buy_million") or 0), 2)
operation_key = (row.get("code"), seat_name, buy, sell, net_buy)
if operation_key in seen_operations:
continue
seen_operations.add(operation_key)
group_key = (identity_type, identity_name)
group = traders.setdefault(
group_key,
{
"name": identity_name,
"identity_type": identity_type,
"identity_source": identity_source,
"recognized": recognized,
"buy_million": 0.0,
"sell_million": 0.0,
"net_buy_million": 0.0,
"seat_names": set(),
"stock_codes": set(),
"operations": [],
},
)
group["buy_million"] += buy
group["sell_million"] += sell
group["net_buy_million"] += net_buy
group["seat_names"].add(seat_name)
group["stock_codes"].add(str(row.get("code") or ""))
group["operations"].append(
{
"code": row.get("code") or "",
"name": row.get("name") or "--",
"change": row.get("change") or 0,
"direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平",
"buy_million": buy,
"sell_million": sell,
"net_buy_million": net_buy,
"reason": row.get("reason") or "--",
"seat_name": seat_name,
"seat_alias": identity_name if recognized else "",
}
)
if not recognized:
pending = unclassified.setdefault(
seat_name,
{
"seat_name": seat_name,
"stock_codes": set(),
"operation_count": 0,
"buy_million": 0.0,
"sell_million": 0.0,
"net_buy_million": 0.0,
},
)
pending["stock_codes"].add(str(row.get("code") or ""))
pending["operation_count"] += 1
pending["buy_million"] += buy
pending["sell_million"] += sell
pending["net_buy_million"] += net_buy
type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3}
aggregated = list(traders.values())
aggregated.sort(
key=lambda item: (
type_order.get(item["identity_type"], 9),
-abs(item["net_buy_million"]),
item["name"],
)
)
for index, group in enumerate(aggregated, start=1):
group["id"] = f"identity-{index}"
group["buy_million"] = round(group["buy_million"], 2)
group["sell_million"] = round(group["sell_million"], 2)
group["net_buy_million"] = round(group["net_buy_million"], 2)
group["seat_count"] = len(group.pop("seat_names"))
group["stock_count"] = len(group.pop("stock_codes"))
group["operation_count"] = len(group["operations"])
group["operations"].sort(
key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True
)
pending_seats = list(unclassified.values())
for pending in pending_seats:
pending["stock_count"] = len(pending.pop("stock_codes"))
pending["buy_million"] = round(pending["buy_million"], 2)
pending["sell_million"] = round(pending["sell_million"], 2)
pending["net_buy_million"] = round(pending["net_buy_million"], 2)
pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True)
operation_count = sum(item["operation_count"] for item in aggregated)
active_stocks = {
operation["code"] for item in aggregated for operation in item["operations"]
}
seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2)
result["rows"] = rows
result["traders"] = aggregated
result["unclassified_seats"] = pending_seats
result["summary"] = {
**(payload.get("summary") or {}),
"trader_count": sum(item["identity_type"] == "trader" for item in aggregated),
"identity_count": len(aggregated),
"operation_count": operation_count,
"active_stock_count": len(active_stocks),
"seat_net_buy_million": seat_net_buy,
"unclassified_count": len(pending_seats),
}
return result
File diff suppressed because it is too large Load Diff
+9
View File
@@ -14,6 +14,7 @@ from backend.bootstrap.config import (
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.market.charts import ChartDataError from backend.features.market.charts import ChartDataError
from backend.features.market.insights import MarketInsightsService
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
@@ -36,6 +37,14 @@ THS_SEARCH_TYPES = {
class MarketServiceMixin: class MarketServiceMixin:
def _market_insights(self) -> MarketInsightsService:
if not self.configured:
raise ValueError("行情数据尚未配置。")
return MarketInsightsService(
self.database,
self._tushare_client(),
ifind=self.ifind,
)
def _tushare_client(self) -> TushareClient: def _tushare_client(self) -> TushareClient:
gateway = getattr(self, "data_gateway", None) gateway = getattr(self, "data_gateway", None)
if gateway is not None: if gateway is not None:
+4
View File
@@ -0,0 +1,4 @@
from .repository import PopularityRepositoryMixin
from .service import PopularityServiceMixin
__all__ = ["PopularityRepositoryMixin", "PopularityServiceMixin"]
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any
class PopularityRepositoryMixin:
def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""),
str(row.get("ts_code") or ""),
int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None,
int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None,
float(row.get("combined_score") or 0),
int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None,
int(bool(row.get("dual_source"))),
)
for row in rows
if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO popularity_factors
(trade_date, ts_code, ths_rank, dc_rank, combined_score,
rank_change, dual_source)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
ths_rank=excluded.ths_rank,
dc_rank=excluded.dc_rank,
combined_score=excluded.combined_score,
rank_change=excluded.rank_change,
dual_source=excluded.dual_source
""",
values,
)
return len(values)
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.market.insights import MarketInsightsService
class PopularityServiceMixin:
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().popularity(normalize_date(trade_date), force)
+3
View File
@@ -0,0 +1,3 @@
from .service import ThemeServiceMixin
__all__ = ["ThemeServiceMixin"]
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.market.insights import MarketInsightsService
class ThemeServiceMixin:
def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().theme_library(normalize_date(trade_date), force)
def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]:
return self._market_insights().theme_detail(code, normalize_date(trade_date))
+6 -140
View File
@@ -8,8 +8,11 @@ from typing import Any
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
from backend.features.accounts.repository import AccountRepositoryMixin from backend.features.accounts.repository import AccountRepositoryMixin
from backend.features.auction.repository import AuctionRepositoryMixin
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
from backend.features.market.repository import MarketRepositoryMixin from backend.features.market.repository import MarketRepositoryMixin
from backend.features.pools.repository import PoolRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin
from backend.features.popularity.repository import PopularityRepositoryMixin
from backend.features.system.repository import SystemSettingsRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin
@@ -24,8 +27,11 @@ def _optional_float(value: Any) -> float | None:
class ReviewDatabase( class ReviewDatabase(
AccountRepositoryMixin, AccountRepositoryMixin,
AuctionRepositoryMixin,
DragonTigerRepositoryMixin,
MarketRepositoryMixin, MarketRepositoryMixin,
PoolRepositoryMixin, PoolRepositoryMixin,
PopularityRepositoryMixin,
SystemSettingsRepositoryMixin, SystemSettingsRepositoryMixin,
): ):
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
@@ -839,24 +845,6 @@ class ReviewDatabase(
return cursor.rowcount > 0 return cursor.rowcount > 0
def list_seat_aliases(self) -> dict[str, str]:
with self.connect() as connection:
rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall()
return {row["seat_name"]: row["alias"] for row in rows}
def save_seat_alias(self, seat_name: str, alias: str) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO seat_aliases (seat_name, alias, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(seat_name) DO UPDATE SET
alias = excluded.alias,
updated_at = excluded.updated_at
""",
(seat_name, alias, now),
)
def list_sector_phase_overrides(self) -> dict[str, str]: def list_sector_phase_overrides(self) -> dict[str, str]:
with self.connect() as connection: with self.connect() as connection:
@@ -1056,44 +1044,6 @@ class ReviewDatabase(
) )
return len(values) return len(values)
def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int:
values = []
for row in rows:
trade_date = str(row.get("trade_date") or "")
ts_code = str(row.get("ts_code") or "")
price = float(row.get("price") or 0)
pre_close = float(row.get("pre_close") or 0)
if not trade_date or not ts_code or price <= 0 or pre_close <= 0:
continue
values.append(
(
trade_date,
ts_code,
price,
pre_close,
(price / pre_close - 1) * 100,
float(row.get("vol") or 0),
float(row.get("amount") or 0),
float(row.get("turnover_rate") or 0),
float(row.get("volume_ratio") or 0),
)
)
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO auction_factors
(trade_date, ts_code, price, pre_close, change, vol, amount,
turnover_rate, volume_ratio)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
price=excluded.price, pre_close=excluded.pre_close,
change=excluded.change, vol=excluded.vol, amount=excluded.amount,
turnover_rate=excluded.turnover_rate,
volume_ratio=excluded.volume_ratio
""",
values,
)
return len(values)
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int: def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
values = [ values = [
@@ -1130,84 +1080,7 @@ class ReviewDatabase(
) )
return len(values) return len(values)
def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""),
str(row.get("ts_code") or ""),
int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None,
int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None,
float(row.get("combined_score") or 0),
int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None,
int(bool(row.get("dual_source"))),
)
for row in rows
if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO popularity_factors
(trade_date, ts_code, ths_rank, dc_rank, combined_score,
rank_change, dual_source)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
ths_rank=excluded.ths_rank,
dc_rank=excluded.dc_rank,
combined_score=excluded.combined_score,
rank_change=excluded.rank_change,
dual_source=excluded.dual_source
""",
values,
)
return len(values)
def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int:
grouped: dict[tuple[str, str], dict[str, float | int]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
ts_code = str(row.get("ts_code") or "")
seat_name = str(row.get("exalter") or row.get("seat_name") or "")
if not trade_date or not ts_code or "机构专用" not in seat_name:
continue
group = grouped.setdefault(
(trade_date, ts_code),
{"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0},
)
group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0)
group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0)
group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0)
group["seats"] = int(group["seats"]) + 1
values = [
(trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"])
for (trade_date, ts_code), item in grouped.items()
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO lhb_institution_daily
(trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
net_buy_amount=excluded.net_buy_amount,
buy_amount=excluded.buy_amount,
sell_amount=excluded.sell_amount,
seat_count=excluded.seat_count
""",
values,
)
return len(values)
def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else ""
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
with self.connect() as connection:
rows = connection.execute(
f"SELECT DISTINCT trade_date FROM auction_factors {where} "
"ORDER BY trade_date DESC LIMIT ?",
parameters,
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]: def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else "" where = "WHERE trade_date <= ?" if end_date else ""
@@ -1227,13 +1100,6 @@ class ReviewDatabase(
).fetchall() ).fetchall()
return [str(row["end_date"]) for row in rows] return [str(row["end_date"]) for row in rows]
def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code",
(trade_date,),
).fetchall()
return [dict(row) for row in rows]
def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]: def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]:
with self.connect() as connection: with self.connect() as connection:
+1 -1312
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ ORIGINAL_ROOT = APP_ROOT.parent
MARKET_METHODS = { MARKET_METHODS = {
"_tushare_client", "_tushare_client",
"_market_insights",
"get_dashboard", "get_dashboard",
"_dashboard_sentiment_ready", "_dashboard_sentiment_ready",
"_display_compact_date", "_display_compact_date",
@@ -0,0 +1,195 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
import market_insights
from backend.features.market import insights as canonical_insights
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
MARKET_INSIGHT_METHODS = {
"__init__",
"_trade_context",
"_latest_feature_snapshot",
"_auction_session",
"_stock_master",
"_expectation_label",
"_auction_confirmation",
"_attention_score",
"_auction_candidates",
"_auction_theme_evidence",
"_auction_amount_history",
"_ensure_auction_amount_history",
"_with_auction_watchlist",
"_dynamic_auction_rows",
"auction_center",
"_theme_directory",
"theme_library",
"theme_detail",
"_parse_concepts",
"popularity",
"_hot_rows",
"_normalize_hot",
}
MARKET_SERVICE_METHODS = {"_market_insights"}
AUCTION_SERVICE_METHODS = {"auction_center"}
THEME_SERVICE_METHODS = {"theme_library", "theme_detail"}
POPULARITY_SERVICE_METHODS = {"popularity"}
DRAGON_TIGER_SERVICE_METHODS = {
"get_hot_money_profiles",
"get_dragon_tiger",
"_apply_seat_aliases",
}
AUCTION_REPOSITORY_METHODS = {
"upsert_auction_factors",
"auction_factor_dates",
"auction_factors_for_date",
}
POPULARITY_REPOSITORY_METHODS = {"upsert_popularity_factors"}
DRAGON_TIGER_REPOSITORY_METHODS = {
"list_seat_aliases",
"save_seat_alias",
"upsert_lhb_institutions",
}
TUSHARE_METHODS = {"hot_money_profiles", "dragon_tiger"}
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
def assert_methods_equal(
self,
original_path: Path,
original_class: str,
migrated_path: Path,
migrated_class: str,
names: set[str],
) -> None:
original = class_methods(original_path, original_class)
migrated = class_methods(migrated_path, migrated_class)
self.assertEqual(set(migrated), names)
for name in sorted(names):
self.assertEqual(migrated[name], original[name], name)
def test_shared_market_insight_service_is_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "market_insights.py",
"MarketInsightsService",
APP_ROOT / "backend" / "features" / "market" / "insights.py",
"MarketInsightsService",
MARKET_INSIGHT_METHODS,
)
self.assertIs(market_insights.MarketInsightsService, canonical_insights.MarketInsightsService)
def test_dashboard_service_methods_are_exact_original_ast(self) -> None:
original = ORIGINAL_ROOT / "server.py"
mappings = (
("auction/service.py", "AuctionServiceMixin", AUCTION_SERVICE_METHODS),
("themes/service.py", "ThemeServiceMixin", THEME_SERVICE_METHODS),
("popularity/service.py", "PopularityServiceMixin", POPULARITY_SERVICE_METHODS),
("dragon_tiger/service.py", "DragonTigerServiceMixin", DRAGON_TIGER_SERVICE_METHODS),
)
for relative, class_name, names in mappings:
with self.subTest(relative=relative):
self.assert_methods_equal(
original,
"DashboardService",
APP_ROOT / "backend" / "features" / relative,
class_name,
names,
)
original_methods = class_methods(original, "DashboardService")
market_methods = class_methods(
APP_ROOT / "backend" / "features" / "market" / "service.py",
"MarketServiceMixin",
)
for name in MARKET_SERVICE_METHODS:
self.assertEqual(market_methods[name], original_methods[name], name)
def test_repository_methods_are_exact_original_ast(self) -> None:
original = ORIGINAL_ROOT / "database.py"
mappings = (
("auction/repository.py", "AuctionRepositoryMixin", AUCTION_REPOSITORY_METHODS),
("popularity/repository.py", "PopularityRepositoryMixin", POPULARITY_REPOSITORY_METHODS),
("dragon_tiger/repository.py", "DragonTigerRepositoryMixin", DRAGON_TIGER_REPOSITORY_METHODS),
)
for relative, class_name, names in mappings:
with self.subTest(relative=relative):
self.assert_methods_equal(
original,
"ReviewDatabase",
APP_ROOT / "backend" / "features" / relative,
class_name,
names,
)
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
remaining_service = class_methods(
APP_ROOT / "backend" / "application.py", "DashboardService"
)
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
moved_service = (
MARKET_SERVICE_METHODS
| AUCTION_SERVICE_METHODS
| THEME_SERVICE_METHODS
| POPULARITY_SERVICE_METHODS
| DRAGON_TIGER_SERVICE_METHODS
)
moved_repository = (
AUCTION_REPOSITORY_METHODS
| POPULARITY_REPOSITORY_METHODS
| DRAGON_TIGER_REPOSITORY_METHODS
)
self.assertTrue(moved_service.isdisjoint(remaining_service))
self.assertTrue(moved_repository.isdisjoint(remaining_database))
def test_tushare_dragon_tiger_implementations_are_exact_original_ast(self) -> None:
original = class_methods(ORIGINAL_ROOT / "tushare_client.py", "TushareClient")
migrated = class_methods(
APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py",
"TushareClient",
)
for name in sorted(TUSHARE_METHODS):
self.assertEqual(migrated[name], original[name], name)
def test_api_and_frontend_assets_are_unchanged(self) -> None:
for relative in (
"config/api.config.json",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/auction/page.js",
"static/pages/themes/page.js",
"static/pages/popularity/page.js",
"static/pages/dragon-tiger/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
def request_json(
opener: urllib.request.OpenerDirector,
url: str,
payload: dict[str, Any] | None = None,
) -> tuple[int, Any]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers)
try:
with opener.open(request, timeout=90) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector:
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
)
status, body = request_json(
opener,
f"{base_url.rstrip('/')}/api/auth/login",
{"username": username, "password": password},
)
if status != 200 or not body.get("ok"):
raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
return opener
def digest(value: Any) -> str:
content = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(content).hexdigest()
def comparable(value: Any) -> Any:
if isinstance(value, dict):
return {
key: comparable(item)
for key, item in value.items()
if key != "request_id"
}
if isinstance(value, list):
return [comparable(item) for item in value]
return value
def main() -> None:
parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
parser.add_argument("--original", required=True)
parser.add_argument("--migrated", required=True)
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("endpoints", nargs="+")
args = parser.parse_args()
original = session(args.original, args.username, args.password)
migrated = session(args.migrated, args.username, args.password)
rows = []
all_equal = True
for endpoint in args.endpoints:
original_status, original_body = request_json(
original, f"{args.original.rstrip('/')}{endpoint}"
)
migrated_status, migrated_body = request_json(
migrated, f"{args.migrated.rstrip('/')}{endpoint}"
)
original_comparable = comparable(original_body)
migrated_comparable = comparable(migrated_body)
equal = original_status == migrated_status and original_comparable == migrated_comparable
all_equal = all_equal and equal
rows.append(
{
"endpoint": endpoint,
"original_status": original_status,
"migrated_status": migrated_status,
"original_sha256": digest(original_comparable),
"migrated_sha256": digest(migrated_comparable),
"equal": equal,
}
)
result = {"all_equal": all_equal, "endpoints": rows}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not all_equal:
raise SystemExit(1)
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
from pathlib import Path
from typing import Any
def digest(value: Any) -> str:
content = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
).encode("utf-8")
return hashlib.sha256(content).hexdigest()
def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
rows = connection.execute(
"""
SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%'
ORDER BY type, name
"""
).fetchall()
return [dict(row) for row in rows]
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
quoted = '"' + table.replace('"', '""') + '"'
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
def main() -> None:
parser = argparse.ArgumentParser(description="Compare preservation SQLite databases")
parser.add_argument("--original", type=Path, required=True)
parser.add_argument("--migrated", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("tables", nargs="+")
args = parser.parse_args()
original = sqlite3.connect(args.original)
migrated = sqlite3.connect(args.migrated)
original.row_factory = sqlite3.Row
migrated.row_factory = sqlite3.Row
try:
original_schema = schema(original)
migrated_schema = schema(migrated)
tables = []
all_equal = original_schema == migrated_schema
for table in args.tables:
original_rows = table_rows(original, table)
migrated_rows = table_rows(migrated, table)
equal = original_rows == migrated_rows
all_equal = all_equal and equal
tables.append(
{
"table": table,
"original_count": len(original_rows),
"migrated_count": len(migrated_rows),
"original_sha256": digest(original_rows),
"migrated_sha256": digest(migrated_rows),
"equal": equal,
}
)
result = {
"all_equal": all_equal,
"schema": {
"object_count": len(original_schema),
"original_sha256": digest(original_schema),
"migrated_sha256": digest(migrated_schema),
"equal": original_schema == migrated_schema,
},
"tables": tables,
}
finally:
original.close()
migrated.close()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not all_equal:
raise SystemExit(1)
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import argparse
import sys
from http.server import ThreadingHTTPServer
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Run an isolated preservation runtime")
parser.add_argument("--runtime-root", type=Path, required=True)
parser.add_argument("--data-dir", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
runtime_root = args.runtime_root.resolve()
data_dir = args.data_dir.resolve()
data_dir.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, str(runtime_root))
if (runtime_root / "backend" / "bootstrap" / "config.py").is_file():
from backend.bootstrap import config
config.DATA_DIR = data_dir
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
else:
import app_config as config
config.DATA_DIR = data_dir
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
from server import RequestHandler, SERVICE
server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler)
print(f"Preservation runtime is running at http://127.0.0.1:{args.port}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
SERVICE._background_stop.set()
server.server_close()
if __name__ == "__main__":
main()