rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -12,6 +12,7 @@ from backend.data.contracts import (
|
||||
DataUsage,
|
||||
MarketEntity,
|
||||
ObservationMetadata,
|
||||
ProviderResult,
|
||||
SnapshotState,
|
||||
TradeContext,
|
||||
)
|
||||
@@ -114,6 +115,39 @@ class DataGateway:
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
||||
|
||||
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
|
||||
requested = _date(through)
|
||||
with self._database.read() as connection:
|
||||
return self._repository.open_dates(connection, requested, limit)
|
||||
|
||||
def stock_directory(self) -> dict[str, dict[str, Any]]:
|
||||
with self._database.read() as connection:
|
||||
rows = self._repository.stock_directory(connection)
|
||||
return {str(row["identifier"]): dict(row) for row in rows}
|
||||
|
||||
def insight_inputs(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
previous_trade_date: str = "",
|
||||
identifier: str = "",
|
||||
) -> dict[str, Any]:
|
||||
provider = self._provider(DataSource.TUSHARE)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
return provider.market_insight(kind, trade_date, previous_trade_date, identifier)
|
||||
|
||||
def dynamic_auction(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult:
|
||||
if not identifiers:
|
||||
raise MarketDataUnavailable("动态竞价候选范围为空")
|
||||
provider = self._provider(DataSource.IFIND)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
result = provider.realtime_snapshots(identifiers, start_time, end_time)
|
||||
if not result.rows:
|
||||
raise MarketDataUnavailable("当前动态竞价快照暂不可用")
|
||||
return result
|
||||
|
||||
def sector_members(
|
||||
self, trade_date: str, sector_name: str, representative: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -30,5 +30,7 @@ class DataSourcePolicy:
|
||||
("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE),
|
||||
("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY),
|
||||
("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE),
|
||||
("market_insight", DataUsage.CALCULATION): (DataSource.TUSHARE,),
|
||||
("dynamic_auction", DataUsage.CALCULATION): (DataSource.IFIND,),
|
||||
}
|
||||
return routes.get((dataset, usage), ())
|
||||
|
||||
@@ -28,3 +28,15 @@ class MarketDataProvider(Protocol):
|
||||
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
previous_trade_date: str = "",
|
||||
identifier: str = "",
|
||||
) -> dict[str, ProviderResult | None]: ...
|
||||
|
||||
def realtime_snapshots(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult: ...
|
||||
|
||||
@@ -102,6 +102,20 @@ class EastmoneyProvider:
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("The display provider is not the constituent authority")
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
previous_trade_date: str = "",
|
||||
identifier: str = "",
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("The display provider cannot supply market insight archives")
|
||||
|
||||
def realtime_snapshots(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult:
|
||||
raise ProviderError("The display provider cannot supply calculation snapshots")
|
||||
|
||||
@staticmethod
|
||||
def _secid(entity_type: str, identifier: str) -> str:
|
||||
if entity_type == "index" and identifier in INDEX_CODES:
|
||||
|
||||
@@ -92,6 +92,51 @@ class IfindProvider:
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
previous_trade_date: str = "",
|
||||
identifier: str = "",
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("iFinD只承担许可范围内的动态竞价快照")
|
||||
|
||||
def realtime_snapshots(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for offset in range(0, len(identifiers), 80):
|
||||
batch = identifiers[offset : offset + 80]
|
||||
if not batch:
|
||||
continue
|
||||
payload = self._request(
|
||||
"snap_shot",
|
||||
{
|
||||
"codes": ",".join(batch),
|
||||
"indicators": "latest,volume,amount,preClose,turnoverRatio,volumeRatio,"
|
||||
"bid1,bidSize1,ask1,askSize1",
|
||||
"starttime": start_time,
|
||||
"endtime": end_time,
|
||||
},
|
||||
)
|
||||
rows.extend(_result(payload, "mixed", "not_applicable", SnapshotState.REALTIME).rows)
|
||||
covered = {
|
||||
str(row.get("thscode") or "") for row in rows if row.get("thscode")
|
||||
}
|
||||
return ProviderResult(
|
||||
tuple(rows),
|
||||
ObservationMetadata(
|
||||
source=self.source,
|
||||
observed_at=datetime.now(SHANGHAI),
|
||||
unit="mixed",
|
||||
adjustment="not_applicable",
|
||||
freshness_seconds=0,
|
||||
coverage=min(len(covered) / max(len(identifiers), 1), 1),
|
||||
state=SnapshotState.REALTIME,
|
||||
usage=DataUsage.CALCULATION,
|
||||
),
|
||||
)
|
||||
|
||||
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.configured:
|
||||
raise ProviderError("实时行情服务尚未配置")
|
||||
|
||||
@@ -189,6 +189,109 @@ class TushareProvider:
|
||||
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
||||
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
previous_trade_date: str = "",
|
||||
identifier: str = "",
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
current = _compact(trade_date)
|
||||
previous = _compact(previous_trade_date) if previous_trade_date else current
|
||||
if kind == "auction":
|
||||
return {
|
||||
"auction": self._optional_query(
|
||||
"stk_auction",
|
||||
{"trade_date": current},
|
||||
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,"
|
||||
"volume_ratio,float_share",
|
||||
),
|
||||
"price_limits": self._optional_query(
|
||||
"stk_limit",
|
||||
{"trade_date": current},
|
||||
"trade_date,ts_code,up_limit,down_limit",
|
||||
),
|
||||
"ths_hot": self._optional_query("ths_hot", {"trade_date": previous}, ""),
|
||||
"dc_hot": self._optional_query("dc_hot", {"trade_date": previous}, ""),
|
||||
}
|
||||
if kind == "themes":
|
||||
return {
|
||||
"directory": self._optional_query(
|
||||
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
|
||||
),
|
||||
"daily": self._optional_query(
|
||||
"ths_daily",
|
||||
{"trade_date": current},
|
||||
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,"
|
||||
"vol,turnover_rate",
|
||||
),
|
||||
"hot": self._optional_query("ths_hot", {"trade_date": current}, ""),
|
||||
}
|
||||
if kind == "theme-detail":
|
||||
return {
|
||||
"members": self._optional_query(
|
||||
"ths_member",
|
||||
{"ts_code": identifier, "is_new": "Y"},
|
||||
"ts_code,con_code,con_name",
|
||||
),
|
||||
"daily": self._optional_query(
|
||||
"daily",
|
||||
{"trade_date": current},
|
||||
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||
),
|
||||
}
|
||||
if kind == "popularity":
|
||||
return {
|
||||
"ths": self._optional_query("ths_hot", {"trade_date": current}, ""),
|
||||
"dc": self._optional_query("dc_hot", {"trade_date": current}, ""),
|
||||
"previous_ths": self._optional_query(
|
||||
"ths_hot", {"trade_date": previous}, ""
|
||||
),
|
||||
"previous_dc": self._optional_query(
|
||||
"dc_hot", {"trade_date": previous}, ""
|
||||
),
|
||||
}
|
||||
if kind == "dragon-list":
|
||||
return {
|
||||
"official": self._optional_query(
|
||||
"hm_detail",
|
||||
{"trade_date": current},
|
||||
"trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount,"
|
||||
"hm_name,hm_orgs,tag",
|
||||
),
|
||||
"profiles": self._optional_query("hm_list", {}, "name,desc,orgs"),
|
||||
"stocks": self._optional_query(
|
||||
"top_list",
|
||||
{"trade_date": current},
|
||||
"trade_date,ts_code,name,pct_change,reason",
|
||||
),
|
||||
"seats": self._optional_query(
|
||||
"top_inst",
|
||||
{"trade_date": current},
|
||||
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
|
||||
),
|
||||
}
|
||||
raise ProviderError("不支持的市场洞察数据集")
|
||||
|
||||
def realtime_snapshots(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult:
|
||||
raise ProviderError("Tushare不提供动态竞价快照")
|
||||
|
||||
def _optional_query(
|
||||
self, api_name: str, params: dict[str, Any], fields: str
|
||||
) -> ProviderResult | None:
|
||||
try:
|
||||
return self._query(
|
||||
api_name,
|
||||
params,
|
||||
fields,
|
||||
unit="mixed",
|
||||
empty_is_complete=True,
|
||||
)
|
||||
except ProviderError:
|
||||
return None
|
||||
|
||||
def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
fields = (
|
||||
|
||||
@@ -79,6 +79,43 @@ class MarketRepository:
|
||||
],
|
||||
)
|
||||
|
||||
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, ...]:
|
||||
@@ -135,6 +172,16 @@ class MarketRepository:
|
||||
).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,
|
||||
@@ -234,6 +281,128 @@ class MarketRepository:
|
||||
),
|
||||
)
|
||||
|
||||
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 watchlist(
|
||||
self, connection: sqlite3.Connection, user_id: int
|
||||
) -> tuple[sqlite3.Row, ...]:
|
||||
return tuple(
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT identifier, name, sector FROM watchlist_entries
|
||||
WHERE user_id = ? ORDER BY created_at, identifier
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def save_chart(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
|
||||
Reference in New Issue
Block a user