564 lines
18 KiB
Python
564 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from backend.data.contracts import MarketEntity
|
|
|
|
|
|
class MarketRepository:
|
|
def replace_calendar(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
rows: tuple[dict[str, Any], ...],
|
|
source: str,
|
|
observed_at: str,
|
|
) -> None:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO trading_days (trade_date, is_open, previous_open_date, source, observed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date) DO UPDATE SET
|
|
is_open = excluded.is_open,
|
|
previous_open_date = excluded.previous_open_date,
|
|
source = excluded.source,
|
|
observed_at = excluded.observed_at
|
|
""",
|
|
[
|
|
(
|
|
_display(str(row.get("cal_date") or "")),
|
|
1 if int(row.get("is_open") or 0) == 1 else 0,
|
|
_display(str(row.get("pretrade_date") or "")) or None,
|
|
source,
|
|
observed_at,
|
|
)
|
|
for row in rows
|
|
if _display(str(row.get("cal_date") or ""))
|
|
],
|
|
)
|
|
|
|
def replace_stocks(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
rows: tuple[dict[str, Any], ...],
|
|
source: str,
|
|
observed_at: str,
|
|
) -> None:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO market_entities (
|
|
entity_type, identifier, code, name, search_key,
|
|
sector, active, source, observed_at
|
|
)
|
|
VALUES ('stock', ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(entity_type, identifier) DO UPDATE SET
|
|
code = excluded.code,
|
|
name = excluded.name,
|
|
search_key = excluded.search_key,
|
|
sector = excluded.sector,
|
|
active = excluded.active,
|
|
source = excluded.source,
|
|
observed_at = excluded.observed_at
|
|
""",
|
|
[
|
|
(
|
|
str(row.get("ts_code") or "").upper(),
|
|
str(row.get("symbol") or ""),
|
|
str(row.get("name") or "").strip(),
|
|
_search_key(row),
|
|
str(row.get("industry") or "").strip() or None,
|
|
0 if row.get("list_status") == "D" else 1,
|
|
source,
|
|
observed_at,
|
|
)
|
|
for row in rows
|
|
if row.get("ts_code") and row.get("symbol") and row.get("name")
|
|
],
|
|
)
|
|
|
|
def replace_themes(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
rows: list[dict[str, Any]],
|
|
source: str,
|
|
observed_at: str,
|
|
) -> None:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO market_entities (
|
|
entity_type, identifier, code, name, search_key,
|
|
sector, active, source, observed_at
|
|
) VALUES ('theme', ?, ?, ?, ?, NULL, 1, ?, ?)
|
|
ON CONFLICT(entity_type, identifier) DO UPDATE SET
|
|
code = excluded.code,
|
|
name = excluded.name,
|
|
search_key = excluded.search_key,
|
|
active = 1,
|
|
source = excluded.source,
|
|
observed_at = excluded.observed_at
|
|
""",
|
|
[
|
|
(
|
|
str(row.get("code") or "").upper(),
|
|
str(row.get("code") or "").split(".")[0],
|
|
str(row.get("name") or "").strip(),
|
|
_normalize(
|
|
f"{row.get('code') or ''} {row.get('name') or ''}"
|
|
),
|
|
source,
|
|
observed_at,
|
|
)
|
|
for row in rows
|
|
if row.get("code") and row.get("name")
|
|
],
|
|
)
|
|
|
|
def search(
|
|
self, connection: sqlite3.Connection, query: str, limit: int = 32
|
|
) -> tuple[MarketEntity, ...]:
|
|
normalized = _normalize(query)
|
|
if not normalized:
|
|
return ()
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT entity_type, identifier, code, name, sector
|
|
FROM market_entities
|
|
WHERE active = 1 AND search_key LIKE ?
|
|
ORDER BY
|
|
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 WHEN code LIKE ? THEN 2 ELSE 3 END,
|
|
entity_type, name
|
|
LIMIT ?
|
|
""",
|
|
(f"%{normalized}%", normalized, query.strip(), f"{normalized}%", limit),
|
|
).fetchall()
|
|
return tuple(MarketEntity(**dict(row)) for row in rows)
|
|
|
|
def entity(
|
|
self, connection: sqlite3.Connection, entity_type: str, identifier: str
|
|
) -> MarketEntity | None:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT entity_type, identifier, code, name, sector
|
|
FROM market_entities WHERE entity_type = ? AND identifier = ? AND active = 1
|
|
""",
|
|
(entity_type, identifier),
|
|
).fetchone()
|
|
return MarketEntity(**dict(row)) if row else None
|
|
|
|
def open_dates(
|
|
self, connection: sqlite3.Connection, through: str, limit: int = 12
|
|
) -> tuple[str, ...]:
|
|
return tuple(
|
|
str(row["trade_date"])
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT trade_date FROM trading_days
|
|
WHERE is_open = 1 AND trade_date <= ?
|
|
ORDER BY trade_date DESC LIMIT ?
|
|
""",
|
|
(through, limit),
|
|
)
|
|
)
|
|
|
|
def open_dates_between(
|
|
self, connection: sqlite3.Connection, start_date: str, end_date: str
|
|
) -> tuple[str, ...]:
|
|
return tuple(
|
|
str(row["trade_date"])
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT trade_date FROM trading_days
|
|
WHERE is_open = 1 AND trade_date BETWEEN ? AND ?
|
|
ORDER BY trade_date
|
|
""",
|
|
(start_date, end_date),
|
|
).fetchall()
|
|
)
|
|
|
|
def active_stock_count(self, connection: sqlite3.Connection) -> int:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT COUNT(*) AS count FROM market_entities
|
|
WHERE entity_type = 'stock' AND active = 1
|
|
"""
|
|
).fetchone()
|
|
return int(row["count"] if row else 0)
|
|
|
|
def stock_directory(self, connection: sqlite3.Connection) -> tuple[sqlite3.Row, ...]:
|
|
return tuple(
|
|
connection.execute(
|
|
"""
|
|
SELECT identifier, code, name, sector FROM market_entities
|
|
WHERE entity_type = 'stock' AND active = 1
|
|
"""
|
|
).fetchall()
|
|
)
|
|
|
|
def save_summary(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
trade_date: str,
|
|
observed_at: str,
|
|
state: str,
|
|
source: str,
|
|
coverage: float,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO market_summaries
|
|
(trade_date, observed_at, state, source, coverage, payload_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date) DO UPDATE SET
|
|
observed_at = excluded.observed_at,
|
|
state = excluded.state,
|
|
source = excluded.source,
|
|
coverage = excluded.coverage,
|
|
payload_json = excluded.payload_json,
|
|
created_at = excluded.created_at
|
|
""",
|
|
(
|
|
trade_date,
|
|
observed_at,
|
|
state,
|
|
source,
|
|
coverage,
|
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
|
datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
),
|
|
)
|
|
|
|
def summaries(
|
|
self, connection: sqlite3.Connection, through: str, limit: int = 260
|
|
) -> tuple[sqlite3.Row, ...]:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT * FROM market_summaries WHERE trade_date <= ?
|
|
ORDER BY trade_date DESC LIMIT ?
|
|
""",
|
|
(through, limit),
|
|
).fetchall()
|
|
return tuple(reversed(rows))
|
|
|
|
def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
|
|
(through,),
|
|
).fetchone()
|
|
|
|
def save_event_revision(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
trade_date: str,
|
|
identifier: str,
|
|
event_type: str,
|
|
reason: str,
|
|
first_time: str,
|
|
last_time: str,
|
|
open_times: int | None,
|
|
source: str,
|
|
priority: int,
|
|
created_by: int | None,
|
|
created_at: str,
|
|
) -> int:
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO market_event_revisions (
|
|
trade_date, identifier, event_type, reason, first_time, last_time,
|
|
open_times, source, priority, created_by, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
trade_date,
|
|
identifier,
|
|
event_type,
|
|
reason,
|
|
first_time,
|
|
last_time,
|
|
open_times,
|
|
source,
|
|
priority,
|
|
created_by,
|
|
created_at,
|
|
),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
def event_revisions(
|
|
self, connection: sqlite3.Connection, trade_date: str
|
|
) -> tuple[sqlite3.Row, ...]:
|
|
return tuple(
|
|
connection.execute(
|
|
"""
|
|
SELECT * FROM (
|
|
SELECT revisions.*,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY identifier, event_type
|
|
ORDER BY priority DESC, id DESC
|
|
) AS rank
|
|
FROM market_event_revisions AS revisions
|
|
WHERE trade_date = ?
|
|
) WHERE rank = 1
|
|
""",
|
|
(trade_date,),
|
|
).fetchall()
|
|
)
|
|
|
|
def event_revision_history(
|
|
self, connection: sqlite3.Connection, trade_date: str, identifier: str
|
|
) -> tuple[sqlite3.Row, ...]:
|
|
return tuple(
|
|
connection.execute(
|
|
"""
|
|
SELECT revisions.*, users.username AS created_by_name
|
|
FROM market_event_revisions AS revisions
|
|
LEFT JOIN users ON users.id = revisions.created_by
|
|
WHERE revisions.trade_date = ? AND revisions.identifier = ?
|
|
ORDER BY revisions.id DESC
|
|
""",
|
|
(trade_date, identifier),
|
|
).fetchall()
|
|
)
|
|
|
|
def sector_members(
|
|
self, connection: sqlite3.Connection, trade_date: str, sector_name: str
|
|
) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT * FROM sector_member_snapshots
|
|
WHERE trade_date = ? AND sector_name = ?
|
|
""",
|
|
(trade_date, sector_name),
|
|
).fetchone()
|
|
|
|
def save_sector_members(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
trade_date: str,
|
|
sector_name: str,
|
|
sector_code: str,
|
|
observed_at: str,
|
|
source: str,
|
|
coverage: float,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO sector_member_snapshots (
|
|
trade_date, sector_name, sector_code, observed_at, source, coverage, payload_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date, sector_name) DO UPDATE SET
|
|
sector_code = excluded.sector_code,
|
|
observed_at = excluded.observed_at,
|
|
source = excluded.source,
|
|
coverage = excluded.coverage,
|
|
payload_json = excluded.payload_json
|
|
""",
|
|
(
|
|
trade_date,
|
|
sector_name,
|
|
sector_code,
|
|
observed_at,
|
|
source,
|
|
coverage,
|
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
|
),
|
|
)
|
|
|
|
def insight_snapshot(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
kind: str,
|
|
trade_date: str,
|
|
entity_key: str = "",
|
|
) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT * FROM market_insight_snapshots
|
|
WHERE kind = ? AND trade_date = ? AND entity_key = ?
|
|
""",
|
|
(kind, trade_date, entity_key),
|
|
).fetchone()
|
|
|
|
def latest_insight_snapshot(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
kind: str,
|
|
through: str,
|
|
entity_key: str = "",
|
|
) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT * FROM market_insight_snapshots
|
|
WHERE kind = ? AND trade_date <= ? AND entity_key = ?
|
|
ORDER BY trade_date DESC LIMIT 1
|
|
""",
|
|
(kind, through, entity_key),
|
|
).fetchone()
|
|
|
|
def insight_snapshots(
|
|
self, connection: sqlite3.Connection, kind: str, through: str, limit: int
|
|
) -> tuple[sqlite3.Row, ...]:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT * FROM market_insight_snapshots
|
|
WHERE kind = ? AND trade_date <= ? AND entity_key = ''
|
|
ORDER BY trade_date DESC LIMIT ?
|
|
""",
|
|
(kind, through, limit),
|
|
).fetchall()
|
|
return tuple(reversed(rows))
|
|
|
|
def save_insight_snapshot(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
kind: str,
|
|
trade_date: str,
|
|
entity_key: str,
|
|
observed_at: str,
|
|
state: str,
|
|
source: str,
|
|
coverage: float,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO market_insight_snapshots (
|
|
kind, trade_date, entity_key, observed_at, state, source, coverage, payload_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(kind, trade_date, entity_key) DO UPDATE SET
|
|
observed_at = excluded.observed_at,
|
|
state = excluded.state,
|
|
source = excluded.source,
|
|
coverage = excluded.coverage,
|
|
payload_json = excluded.payload_json
|
|
""",
|
|
(
|
|
kind,
|
|
trade_date,
|
|
entity_key,
|
|
observed_at,
|
|
state,
|
|
source,
|
|
coverage,
|
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
|
),
|
|
)
|
|
|
|
def seat_aliases(self, connection: sqlite3.Connection) -> dict[str, str]:
|
|
return {
|
|
str(row["seat_name"]): str(row["alias_name"])
|
|
for row in connection.execute(
|
|
"SELECT seat_name, alias_name FROM seat_aliases ORDER BY seat_name"
|
|
).fetchall()
|
|
}
|
|
|
|
def save_seat_alias(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
seat_name: str,
|
|
alias_name: str,
|
|
updated_at: str,
|
|
updated_by: int,
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO seat_aliases (seat_name, alias_name, updated_at, updated_by)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(seat_name) DO UPDATE SET
|
|
alias_name = excluded.alias_name,
|
|
updated_at = excluded.updated_at,
|
|
updated_by = excluded.updated_by
|
|
""",
|
|
(seat_name, alias_name, updated_at, updated_by),
|
|
)
|
|
|
|
def save_chart(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
entity_type: str,
|
|
identifier: str,
|
|
interval: str,
|
|
trade_date: str,
|
|
observed_at: str,
|
|
source: str,
|
|
usage: str,
|
|
adjustment: str,
|
|
coverage: float,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO chart_series
|
|
(entity_type, identifier, interval, trade_date, observed_at, source, usage,
|
|
adjustment, coverage, payload_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(entity_type, identifier, interval, trade_date) DO UPDATE SET
|
|
observed_at = excluded.observed_at,
|
|
source = excluded.source,
|
|
usage = excluded.usage,
|
|
adjustment = excluded.adjustment,
|
|
coverage = excluded.coverage,
|
|
payload_json = excluded.payload_json,
|
|
created_at = excluded.created_at
|
|
""",
|
|
(
|
|
entity_type,
|
|
identifier,
|
|
interval,
|
|
trade_date,
|
|
observed_at,
|
|
source,
|
|
usage,
|
|
adjustment,
|
|
coverage,
|
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
|
datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
),
|
|
)
|
|
|
|
def chart(
|
|
self, connection: sqlite3.Connection, entity_type: str, identifier: str, interval: str
|
|
) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT * FROM chart_series
|
|
WHERE entity_type = ? AND identifier = ? AND interval = ?
|
|
ORDER BY trade_date DESC LIMIT 1
|
|
""",
|
|
(entity_type, identifier, interval),
|
|
).fetchone()
|
|
|
|
|
|
def _display(value: str) -> str:
|
|
compact = value.replace("-", "")
|
|
if len(compact) != 8 or not compact.isdigit():
|
|
return ""
|
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
|
|
|
|
|
def _normalize(value: str) -> str:
|
|
return re.sub(r"\s+", "", value).casefold()
|
|
|
|
|
|
def _search_key(row: dict[str, Any]) -> str:
|
|
return " ".join(
|
|
filter(
|
|
None,
|
|
(
|
|
_normalize(str(row.get("symbol") or "")),
|
|
_normalize(str(row.get("ts_code") or "")),
|
|
_normalize(str(row.get("name") or "")),
|
|
_normalize(str(row.get("industry") or "")),
|
|
),
|
|
)
|
|
)
|