319 lines
12 KiB
Python
319 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
from typing import Any
|
|
|
|
from backend.data.numbers import non_nan_number as _number
|
|
from backend.data.providers.ifind_client import IfindError
|
|
from backend.data.providers.tushare_client import TushareError
|
|
from backend.features.market.insights_context import CHINA_TIMEZONE, _display_date
|
|
|
|
|
|
class MarketAuctionDataMixin:
|
|
def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]:
|
|
now = self._now_provider()
|
|
if now.tzinfo is None:
|
|
now = now.replace(tzinfo=CHINA_TIMEZONE)
|
|
else:
|
|
now = now.astimezone(CHINA_TIMEZONE)
|
|
requested = str(requested_date or "").replace("-", "")
|
|
today = now.strftime("%Y%m%d")
|
|
if requested != today or trade_date != today:
|
|
return {
|
|
"phase": "archive",
|
|
"actionable": False,
|
|
"next_transition_at": "",
|
|
}
|
|
|
|
local_time = now.time().replace(tzinfo=None)
|
|
transitions = (
|
|
(dt_time(9, 15), "pending", dt_time(9, 15)),
|
|
(dt_time(9, 25), "observing", dt_time(9, 25)),
|
|
(dt_time(9, 30), "selection", dt_time(9, 30)),
|
|
)
|
|
for boundary, phase, next_boundary in transitions:
|
|
if local_time < boundary:
|
|
transition = now.replace(
|
|
hour=next_boundary.hour,
|
|
minute=next_boundary.minute,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
return {
|
|
"phase": phase,
|
|
"actionable": phase == "selection",
|
|
"next_transition_at": transition.isoformat(timespec="seconds"),
|
|
}
|
|
return {
|
|
"phase": "finalized",
|
|
"actionable": False,
|
|
"next_transition_at": "",
|
|
}
|
|
|
|
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
|
|
dates = self.database.auction_factor_dates(trade_date, 10)
|
|
stock_list_dates = {
|
|
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
|
|
for item in self.database.list_stock_master()
|
|
if item.get("ts_code")
|
|
}
|
|
history = []
|
|
for current_date in dates:
|
|
rows = [
|
|
row for row in self.database.auction_factors_for_date(current_date)
|
|
if (
|
|
str(row.get("ts_code") or "") in stock_list_dates
|
|
and (
|
|
not stock_list_dates[str(row.get("ts_code") or "")]
|
|
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
|
|
)
|
|
)
|
|
]
|
|
history.append(
|
|
{
|
|
"trade_date": _display_date(current_date),
|
|
"amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2),
|
|
"stock_count": len(rows),
|
|
}
|
|
)
|
|
return history
|
|
|
|
def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None:
|
|
existing = set(self.database.auction_factor_dates(trade_date, target_days + 5))
|
|
if len(existing) >= target_days:
|
|
return
|
|
end = datetime.strptime(trade_date, "%Y%m%d")
|
|
start = (end - timedelta(days=35)).strftime("%Y%m%d")
|
|
try:
|
|
calendar = self.client.query(
|
|
"trade_cal",
|
|
{
|
|
"exchange": "SSE",
|
|
"start_date": start,
|
|
"end_date": trade_date,
|
|
"is_open": 1,
|
|
},
|
|
"cal_date,is_open",
|
|
)
|
|
except TushareError:
|
|
return
|
|
dates = sorted(
|
|
str(item.get("cal_date") or "")
|
|
for item in calendar
|
|
if int(_number(item.get("is_open"))) == 1 and item.get("cal_date")
|
|
)[-target_days:]
|
|
for current_date in dates:
|
|
if current_date in existing:
|
|
continue
|
|
try:
|
|
rows = self.client.query(
|
|
"stk_auction",
|
|
{"trade_date": current_date},
|
|
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
|
|
)
|
|
except TushareError:
|
|
break
|
|
if rows:
|
|
self.database.upsert_auction_factors(rows)
|
|
existing.add(current_date)
|
|
|
|
def _with_auction_watchlist(
|
|
self,
|
|
result: dict[str, Any],
|
|
trade_date: str,
|
|
user_id: int,
|
|
) -> dict[str, Any]:
|
|
personalized = copy.deepcopy(result)
|
|
if not user_id:
|
|
personalized["watchlist_rows"] = []
|
|
personalized["watchlist_missing_count"] = 0
|
|
return personalized
|
|
watched = self.database.list_watchlist(user_id)
|
|
if not watched:
|
|
personalized["watchlist_rows"] = []
|
|
personalized["watchlist_missing_count"] = 0
|
|
return personalized
|
|
|
|
public_rows = {
|
|
str(item.get("code") or ""): item
|
|
for item in (
|
|
list(personalized.get("rows") or [])
|
|
+ list(personalized.get("one_price_rows") or [])
|
|
)
|
|
}
|
|
factors = {
|
|
str(item.get("ts_code") or "").split(".")[0]: item
|
|
for item in self.database.auction_factors_for_date(trade_date)
|
|
}
|
|
master = {
|
|
str(item.get("ts_code") or "").split(".")[0]: item
|
|
for item in self.database.list_stock_master()
|
|
}
|
|
rows = []
|
|
missing = 0
|
|
for item in watched:
|
|
code = str(item.get("code") or "")
|
|
if code in public_rows:
|
|
rows.append({**public_rows[code], "is_watchlist": True})
|
|
continue
|
|
factor = factors.get(code)
|
|
if not factor:
|
|
missing += 1
|
|
rows.append(
|
|
{
|
|
"code": code,
|
|
"name": str(item.get("name") or "--"),
|
|
"sector": str(item.get("sector") or "其他"),
|
|
"available": False,
|
|
"is_watchlist": True,
|
|
}
|
|
)
|
|
continue
|
|
stock = master.get(code, {})
|
|
price = _number(factor.get("price"))
|
|
pre_close = _number(factor.get("pre_close"))
|
|
change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0
|
|
row = {
|
|
"code": code,
|
|
"ts_code": str(factor.get("ts_code") or ""),
|
|
"name": str(item.get("name") or stock.get("name") or "--"),
|
|
"sector": str(item.get("sector") or stock.get("industry") or "其他"),
|
|
"price": round(price, 2),
|
|
"pre_close": round(pre_close, 2),
|
|
"change": round(change, 2),
|
|
"amount_million": round(_number(factor.get("amount")) / 1_000_000, 2),
|
|
"turnover_rate": round(_number(factor.get("turnover_rate")), 4),
|
|
"volume_ratio": round(_number(factor.get("volume_ratio")), 2),
|
|
"candidate_sources": ["我的自选"],
|
|
"source_label": "我的自选",
|
|
"prior_streak": 0,
|
|
"concepts": [],
|
|
"expected_change": 0.0,
|
|
"core_tags": [],
|
|
"is_market_core": False,
|
|
"is_watchlist": True,
|
|
"available": True,
|
|
}
|
|
actual_strength = change + self._auction_confirmation(row)
|
|
row["actual_strength"] = round(actual_strength, 2)
|
|
row["expectation"] = self._expectation_label(actual_strength, 0.0)
|
|
row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False)
|
|
direction = "高于" if change > 0 else "低于" if change < 0 else "贴合"
|
|
row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}"
|
|
rows.append(row)
|
|
rows.sort(
|
|
key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))),
|
|
reverse=True,
|
|
)
|
|
personalized["watchlist_rows"] = rows
|
|
personalized["watchlist_missing_count"] = missing
|
|
return personalized
|
|
|
|
def _dynamic_auction_rows(
|
|
self,
|
|
trade_date: str,
|
|
baseline_date: str,
|
|
user_id: int,
|
|
) -> list[dict[str, Any]]:
|
|
if not self.ifind or not self.ifind.configured:
|
|
return []
|
|
master = self._stock_master()
|
|
placeholders = [
|
|
{
|
|
"code": str(item.get("code") or ts_code.split(".")[0]),
|
|
"ts_code": ts_code,
|
|
"name": str(item.get("name") or "--"),
|
|
"sector": str(item.get("industry") or "其他"),
|
|
}
|
|
for ts_code, item in master.items()
|
|
]
|
|
candidates, _, _ = self._auction_candidates(placeholders, baseline_date)
|
|
selected_codes = {
|
|
str(item.get("ts_code") or "")
|
|
for item in candidates
|
|
if item.get("ts_code")
|
|
}
|
|
if user_id:
|
|
watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)}
|
|
selected_codes.update(
|
|
ts_code for ts_code in master if ts_code.split(".")[0] in watched
|
|
)
|
|
selected_codes.discard("")
|
|
if not selected_codes:
|
|
return []
|
|
|
|
display_date = _display_date(trade_date)
|
|
now = self._now_provider()
|
|
if now.tzinfo is None:
|
|
now = now.replace(tzinfo=CHINA_TIMEZONE)
|
|
else:
|
|
now = now.astimezone(CHINA_TIMEZONE)
|
|
end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25))
|
|
end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}"
|
|
start_stamp = f"{display_date} 09:15:00"
|
|
snapshot_rows: list[dict[str, Any]] = []
|
|
ordered_codes = sorted(selected_codes)
|
|
for index in range(0, len(ordered_codes), 80):
|
|
try:
|
|
snapshot_rows.extend(
|
|
self.ifind.snapshots(
|
|
ordered_codes[index:index + 80],
|
|
[
|
|
"latest", "volume", "amount", "preClose",
|
|
"bid1", "bidSize1", "ask1", "askSize1",
|
|
],
|
|
start_stamp,
|
|
end_stamp,
|
|
cache_ttl=8,
|
|
)
|
|
)
|
|
except IfindError:
|
|
continue
|
|
|
|
latest: dict[str, dict[str, Any]] = {}
|
|
for row in snapshot_rows:
|
|
ts_code = str(row.get("thscode") or "")
|
|
previous = latest.get(ts_code) or {}
|
|
if (
|
|
ts_code
|
|
and _number(row.get("latest")) > 0
|
|
and str(row.get("time") or "") >= str(previous.get("time") or "")
|
|
):
|
|
latest[ts_code] = row
|
|
prior_factors = {
|
|
str(item.get("ts_code") or ""): item
|
|
for item in self.database.auction_factors_for_date(baseline_date)
|
|
}
|
|
normalized = []
|
|
for ts_code, row in latest.items():
|
|
price = _number(row.get("latest"))
|
|
pre_close = _number(row.get("preClose"))
|
|
volume = _number(row.get("volume"))
|
|
bid_size = _number(row.get("bidSize1"))
|
|
ask_size = _number(row.get("askSize1"))
|
|
if volume <= 0 and bid_size > 0 and ask_size > 0:
|
|
volume = min(bid_size, ask_size)
|
|
amount = _number(row.get("amount"))
|
|
if amount <= 0 and price > 0 and volume > 0:
|
|
amount = price * volume
|
|
prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol"))
|
|
normalized.append(
|
|
{
|
|
"ts_code": ts_code,
|
|
"trade_date": trade_date,
|
|
"vol": volume,
|
|
"price": price,
|
|
"amount": amount,
|
|
"pre_close": pre_close,
|
|
"turnover_rate": 0,
|
|
"volume_ratio": volume / prior_volume if prior_volume > 0 else 0,
|
|
"float_share": 0,
|
|
"bid_size1": bid_size,
|
|
"ask_size1": ask_size,
|
|
"snapshot_time": str(row.get("time") or ""),
|
|
"dynamic": True,
|
|
}
|
|
)
|
|
return normalized
|