feat(selection): 迁移知行B1选股策略
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Selection bounded context for reproducible historical strategy evaluation."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Selection application use cases."""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Application use case for one-stock historical Zhixing B1 evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from ..domain.models import SelectionEvaluation, StockHistory
|
||||
from ..domain.ports import MarketDataReader, MarketDataReaderError
|
||||
from ..domain.zhixing_b1 import ZhixingB1Strategy
|
||||
|
||||
|
||||
class EvaluateZhixingB1:
|
||||
"""Read one history, evaluate the pure strategy, and map read failures."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: MarketDataReader,
|
||||
strategy: ZhixingB1Strategy | None = None,
|
||||
) -> None:
|
||||
"""Inject the market-data port and optionally a strategy instance."""
|
||||
|
||||
self.reader = reader
|
||||
self.strategy = strategy or ZhixingB1Strategy()
|
||||
|
||||
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
|
||||
"""Evaluate ``ts_code`` on the exact requested trading date."""
|
||||
|
||||
try:
|
||||
history = self.reader.load_history(ts_code, target_trade_date)
|
||||
except MarketDataReaderError as exc:
|
||||
return SelectionEvaluation(
|
||||
ts_code=ts_code,
|
||||
target_trade_date=target_trade_date,
|
||||
status="data_error",
|
||||
reason=str(exc),
|
||||
)
|
||||
return self.strategy.evaluate(history, target_trade_date)
|
||||
|
||||
def execute_history(
|
||||
self,
|
||||
history: StockHistory,
|
||||
target_trade_date: date,
|
||||
) -> SelectionEvaluation:
|
||||
"""Evaluate an already loaded history for deterministic unit tests."""
|
||||
|
||||
return self.strategy.evaluate(history, target_trade_date)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Selection bounded context
|
||||
|
||||
`selection` owns formula semantics and historical evaluation models for
|
||||
`zhixing_b1`. Its domain imports only Pandas/NumPy and its own models/ports;
|
||||
PostgreSQL remains behind `infrastructure/postgres_reader.py`. This first slice
|
||||
does not expose HTTP routes or write signal records.
|
||||
@@ -0,0 +1 @@
|
||||
"""Pure selection domain models, indicators, ports, and strategies."""
|
||||
@@ -0,0 +1,230 @@
|
||||
"""TDX-style indicator primitives used by the Zhixing B1 formula.
|
||||
|
||||
All inputs are ascending by trading date. Rolling functions intentionally
|
||||
use available observations for the early rows, while ``EVERY`` keeps its
|
||||
full-window requirement. This matches the legacy formula's warm-up behavior
|
||||
without using future rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _check_window(window: int) -> None:
|
||||
"""Validate a positive TDX lookback window."""
|
||||
|
||||
if window < 1:
|
||||
raise ValueError("window must be positive")
|
||||
|
||||
|
||||
def MA(series: pd.Series, window: int) -> pd.Series:
|
||||
"""Return a simple moving average with available-row warm-up."""
|
||||
|
||||
_check_window(window)
|
||||
return series.rolling(window=window, min_periods=1).mean()
|
||||
|
||||
|
||||
def EMA(series: pd.Series, window: int) -> pd.Series:
|
||||
"""Return an adjust-false exponential moving average."""
|
||||
|
||||
_check_window(window)
|
||||
return series.ewm(span=window, adjust=False, min_periods=1).mean()
|
||||
|
||||
|
||||
def LLV(series: pd.Series, window: int) -> pd.Series:
|
||||
"""Return the lowest value in the trailing window."""
|
||||
|
||||
_check_window(window)
|
||||
return series.rolling(window=window, min_periods=1).min()
|
||||
|
||||
|
||||
def HHV(series: pd.Series, window: int) -> pd.Series:
|
||||
"""Return the highest value in the trailing window."""
|
||||
|
||||
_check_window(window)
|
||||
return series.rolling(window=window, min_periods=1).max()
|
||||
|
||||
|
||||
def SMA(series: pd.Series, window: int, weight: int = 1) -> pd.Series:
|
||||
"""Return TDX ``SMA(X,N,M)`` using its recursive weighted average."""
|
||||
|
||||
_check_window(window)
|
||||
if weight < 0 or weight > window:
|
||||
raise ValueError("weight must be between zero and window")
|
||||
return series.ewm(alpha=weight / window, adjust=False, min_periods=1).mean()
|
||||
|
||||
|
||||
def REF(series: pd.Series, periods: int) -> pd.Series:
|
||||
"""Return the value ``periods`` trading rows ago."""
|
||||
|
||||
if periods < 0:
|
||||
raise ValueError("periods must not be negative")
|
||||
return series.shift(periods)
|
||||
|
||||
|
||||
def EXIST(condition: pd.Series, window: int) -> pd.Series:
|
||||
"""Return whether a condition occurred at least once in the window."""
|
||||
|
||||
_check_window(window)
|
||||
values = condition.fillna(False).astype(bool).astype(float)
|
||||
return values.rolling(window=window, min_periods=1).max().astype(bool)
|
||||
|
||||
|
||||
def EVERY(condition: pd.Series, window: int) -> pd.Series:
|
||||
"""Return whether every row in a complete trailing window is true."""
|
||||
|
||||
_check_window(window)
|
||||
values = condition.fillna(False).astype(bool).astype(float)
|
||||
return values.rolling(window=window, min_periods=window).min().fillna(0).astype(bool)
|
||||
|
||||
|
||||
def COUNT(condition: pd.Series, window: int) -> pd.Series:
|
||||
"""Count true rows in the trailing window."""
|
||||
|
||||
_check_window(window)
|
||||
values = condition.fillna(False).astype(bool).astype(float)
|
||||
return values.rolling(window=window, min_periods=1).sum()
|
||||
|
||||
|
||||
def HHVBARS(series: pd.Series, window: int) -> pd.Series:
|
||||
"""Return periods since the most recent trailing maximum."""
|
||||
|
||||
_check_window(window)
|
||||
values = series.to_numpy(dtype=float)
|
||||
result = np.full(len(values), np.nan, dtype=float)
|
||||
for index in range(len(values)):
|
||||
start = max(0, index - window + 1)
|
||||
trailing = values[start : index + 1]
|
||||
finite = np.isfinite(trailing)
|
||||
if not finite.any():
|
||||
continue
|
||||
maximum = np.nanmax(trailing)
|
||||
latest = np.flatnonzero(finite & (trailing == maximum))[-1]
|
||||
result[index] = len(trailing) - 1 - int(latest)
|
||||
return pd.Series(result, index=series.index, dtype=float)
|
||||
|
||||
|
||||
def BARSLAST(condition: pd.Series) -> pd.Series:
|
||||
"""Return periods since the most recent true row, or NaN before one."""
|
||||
|
||||
values = condition.fillna(False).astype(bool).to_numpy()
|
||||
result = np.full(len(values), np.nan, dtype=float)
|
||||
last_true = -1
|
||||
for index, matched in enumerate(values):
|
||||
if matched:
|
||||
last_true = index
|
||||
if last_true >= 0:
|
||||
result[index] = index - last_true
|
||||
return pd.Series(result, index=condition.index, dtype=float)
|
||||
|
||||
|
||||
def CROSS(left: pd.Series, right: pd.Series) -> pd.Series:
|
||||
"""Return rows where ``left`` crosses from below to at-or-above right."""
|
||||
|
||||
previous_left = REF(left, 1)
|
||||
previous_right = REF(right, 1)
|
||||
return (
|
||||
previous_left.notna()
|
||||
& previous_right.notna()
|
||||
& left.notna()
|
||||
& right.notna()
|
||||
& (previous_left < previous_right)
|
||||
& (left >= right)
|
||||
)
|
||||
|
||||
|
||||
def compute_kdj(frame: pd.DataFrame, window: int = 9) -> pd.DataFrame:
|
||||
"""Compute ascending-data K, D and J values.
|
||||
|
||||
A zero high-low range is represented as NaN. K and D carry their prior
|
||||
state across such a row, while J remains NaN there, preventing a flat or
|
||||
incomplete bar from becoming an oversold signal.
|
||||
"""
|
||||
|
||||
_check_window(window)
|
||||
if frame.empty:
|
||||
return pd.DataFrame(index=frame.index, data={"K": [], "D": [], "J": []})
|
||||
low = LLV(frame["low"], window)
|
||||
high = HHV(frame["high"], window)
|
||||
denominator = high - low
|
||||
rsv = ((frame["close"] - low) / denominator.replace(0, np.nan) * 100).to_numpy(float)
|
||||
k = np.full(len(rsv), np.nan, dtype=float)
|
||||
d = np.full(len(rsv), np.nan, dtype=float)
|
||||
previous_k = 50.0
|
||||
previous_d = 50.0
|
||||
for index, value in enumerate(rsv):
|
||||
if np.isfinite(value):
|
||||
previous_k = (2.0 * previous_k + value) / 3.0
|
||||
previous_d = (2.0 * previous_d + previous_k) / 3.0
|
||||
k[index] = previous_k
|
||||
d[index] = previous_d
|
||||
j = 3.0 * k - 2.0 * d
|
||||
return pd.DataFrame(index=frame.index, data={"K": k, "D": d, "J": j})
|
||||
|
||||
|
||||
def compute_rsi(close: pd.Series, window: int = 3) -> pd.Series:
|
||||
"""Compute TDX RSI from close prices, preserving zero-denominator NaN."""
|
||||
|
||||
_check_window(window)
|
||||
previous = REF(close, 1)
|
||||
change = close - previous
|
||||
gain = change.clip(lower=0)
|
||||
absolute_change = change.abs()
|
||||
denominator = SMA(absolute_change, window, 1)
|
||||
return SMA(gain, window, 1).div(denominator.replace(0, np.nan)).mul(100)
|
||||
|
||||
|
||||
def compute_zhixing_lines(close: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
"""Return the formula's trend white line and 4-MA yellow line."""
|
||||
|
||||
white = EMA(EMA(close, 10), 10)
|
||||
yellow = (MA(close, 14) + MA(close, 28) + MA(close, 57) + MA(close, 114)) / 4
|
||||
return white, yellow
|
||||
|
||||
|
||||
def is_wide_limit(code: str) -> bool:
|
||||
"""Return whether a code belongs to the 20-percent-limit prefixes."""
|
||||
|
||||
return code.startswith(("68", "30", "4", "8", "9"))
|
||||
|
||||
|
||||
def compute_amplitude_params(code: str, close: pd.Series | pd.DataFrame) -> tuple[float, float]:
|
||||
"""Return ``(daily_range_limit, change_relaxation)`` for one history.
|
||||
|
||||
Ordinary stocks are widened when a more-than-15-percent historical move
|
||||
appears in the available trailing 200 trading rows. The function accepts
|
||||
either a close series or a frame containing ``close`` for test and caller
|
||||
convenience.
|
||||
"""
|
||||
|
||||
values = close["close"] if isinstance(close, pd.DataFrame) else close
|
||||
wide = is_wide_limit(code)
|
||||
if not wide and not values.empty:
|
||||
ratio = values / REF(values, 1)
|
||||
wide = bool(EXIST(ratio > 1.15, min(200, len(values))).iloc[-1])
|
||||
return (8.0, 0.9) if wide else (5.0, 1.0)
|
||||
|
||||
|
||||
def finite_or_none(value: object) -> float | None:
|
||||
"""Convert one numeric scalar to a JSON-safe float or ``None``."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
number = float(str(value))
|
||||
return number if np.isfinite(number) else None
|
||||
|
||||
|
||||
def serializable_metrics(values: Sequence[tuple[str, object]]) -> dict[str, float | str | None]:
|
||||
"""Convert target-row metrics into a JSON-safe details mapping."""
|
||||
|
||||
result: dict[str, float | str | None] = {}
|
||||
for key, value in values:
|
||||
if isinstance(value, str) or value is None:
|
||||
result[key] = value
|
||||
else:
|
||||
result[key] = finite_or_none(value)
|
||||
return result
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Stable, storage-independent models used by the selection domain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from enum import StrEnum
|
||||
from math import isfinite
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def _validate_number(value: float | None, field_name: str) -> None:
|
||||
"""Reject infinities while allowing ``None`` for incomplete source rows."""
|
||||
|
||||
if value is not None and not isfinite(value):
|
||||
raise ValueError(f"{field_name} must be finite or None")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionBar:
|
||||
"""One qfq daily OHLCV row in the strategy's numeric vocabulary.
|
||||
|
||||
Source nulls are retained as ``None`` so a missing target price cannot be
|
||||
silently converted into a signal. The PostgreSQL adapter performs the
|
||||
Decimal-to-float conversion at this boundary.
|
||||
"""
|
||||
|
||||
trade_date: date
|
||||
open: float | None
|
||||
high: float | None
|
||||
low: float | None
|
||||
close: float | None
|
||||
volume: float | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate that source numbers are finite when present."""
|
||||
|
||||
for field_name in ("open", "high", "low", "close", "volume"):
|
||||
_validate_number(getattr(self, field_name), field_name)
|
||||
|
||||
@property
|
||||
def vol(self) -> float | None:
|
||||
"""Return the database-compatible alias for ``volume``."""
|
||||
|
||||
return self.volume
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionDailyBasic:
|
||||
"""Same-day optional valuation and liquidity facts."""
|
||||
|
||||
trade_date: date
|
||||
turnover_rate: float | None = None
|
||||
total_mv: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate optional numerical facts without inventing missing data."""
|
||||
|
||||
_validate_number(self.turnover_rate, "turnover_rate")
|
||||
_validate_number(self.total_mv, "total_mv")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StockHistory:
|
||||
"""A stock's ascending qfq bars and date-indexed daily-basic facts."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
bars: tuple[SelectionBar, ...] = field(default_factory=tuple)
|
||||
daily_basic: Mapping[date, SelectionDailyBasic] = field(
|
||||
default_factory=lambda: dict[date, SelectionDailyBasic]()
|
||||
)
|
||||
|
||||
@property
|
||||
def daily_basics(self) -> Mapping[date, SelectionDailyBasic]:
|
||||
"""Return the plural alias used by some callers."""
|
||||
|
||||
return self.daily_basic
|
||||
|
||||
|
||||
class ZhixingB1Category(StrEnum):
|
||||
"""The seven independent, persistence-ready B1 sub-signal categories."""
|
||||
|
||||
OVERSOLD_TURN = "zhixing_b1_oversold_turn"
|
||||
OVERSOLD_VOLUME = "zhixing_b1_oversold_volume"
|
||||
ORIGINAL_B1 = "zhixing_b1_original_b1"
|
||||
EXTREME_VOLUME = "zhixing_b1_extreme_volume"
|
||||
PULLBACK_WHITE = "zhixing_b1_pullback_white"
|
||||
PULLBACK_SUPER = "zhixing_b1_pullback_super"
|
||||
PULLBACK_YELLOW = "zhixing_b1_pullback_yellow"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionSignal:
|
||||
"""One explainable B1 hit with a stable identity."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
target_trade_date: date
|
||||
strategy: Literal["zhixing_b1"]
|
||||
category: ZhixingB1Category
|
||||
close: float
|
||||
details: Mapping[str, float | str | None] = field(
|
||||
default_factory=lambda: dict[str, float | str | None]()
|
||||
)
|
||||
|
||||
@property
|
||||
def identity(self) -> tuple[str, date, str, str]:
|
||||
"""Return the future persistence key for this signal."""
|
||||
|
||||
return (
|
||||
self.ts_code,
|
||||
self.target_trade_date,
|
||||
self.strategy,
|
||||
self.category.value,
|
||||
)
|
||||
|
||||
|
||||
SelectionEvaluationStatus = Literal[
|
||||
"selected",
|
||||
"no_signal",
|
||||
"insufficient_history",
|
||||
"missing_target_bar",
|
||||
"data_error",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionEvaluation:
|
||||
"""Result of evaluating one stock on one explicit trade date."""
|
||||
|
||||
ts_code: str
|
||||
target_trade_date: date
|
||||
status: SelectionEvaluationStatus
|
||||
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
|
||||
reason: str | None = None
|
||||
|
||||
@property
|
||||
def selected(self) -> bool:
|
||||
"""Return whether at least one independent sub-signal matched."""
|
||||
|
||||
return self.status == "selected"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Ports that keep selection formulas independent from storage technology."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Protocol
|
||||
|
||||
from .models import StockHistory
|
||||
|
||||
|
||||
class MarketDataReaderError(RuntimeError):
|
||||
"""A market-data adapter could not complete a read."""
|
||||
|
||||
|
||||
class MarketDataReader(Protocol):
|
||||
"""Read qfq history sufficient for one historical strategy evaluation."""
|
||||
|
||||
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
|
||||
@@ -0,0 +1,565 @@
|
||||
"""Formula-level implementation of the seven Zhixing B1 sub-signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .indicators import (
|
||||
BARSLAST,
|
||||
COUNT,
|
||||
CROSS,
|
||||
EVERY,
|
||||
HHV,
|
||||
LLV,
|
||||
MA,
|
||||
REF,
|
||||
compute_amplitude_params,
|
||||
compute_kdj,
|
||||
compute_rsi,
|
||||
compute_zhixing_lines,
|
||||
serializable_metrics,
|
||||
)
|
||||
from .models import (
|
||||
SelectionEvaluation,
|
||||
SelectionSignal,
|
||||
StockHistory,
|
||||
ZhixingB1Category,
|
||||
)
|
||||
|
||||
ZHIXING_B1_SIGNAL_ORDER: tuple[ZhixingB1Category, ...] = (
|
||||
ZhixingB1Category.OVERSOLD_TURN,
|
||||
ZhixingB1Category.OVERSOLD_VOLUME,
|
||||
ZhixingB1Category.ORIGINAL_B1,
|
||||
ZhixingB1Category.EXTREME_VOLUME,
|
||||
ZhixingB1Category.PULLBACK_WHITE,
|
||||
ZhixingB1Category.PULLBACK_SUPER,
|
||||
ZhixingB1Category.PULLBACK_YELLOW,
|
||||
)
|
||||
|
||||
_SIGNAL_LABELS: Mapping[ZhixingB1Category, str] = {
|
||||
ZhixingB1Category.OVERSOLD_TURN: "超卖缩量拐头B",
|
||||
ZhixingB1Category.OVERSOLD_VOLUME: "超卖缩量B",
|
||||
ZhixingB1Category.ORIGINAL_B1: "原始B1",
|
||||
ZhixingB1Category.EXTREME_VOLUME: "超卖超缩量B",
|
||||
ZhixingB1Category.PULLBACK_WHITE: "回踩白线B",
|
||||
ZhixingB1Category.PULLBACK_SUPER: "回踩超级B",
|
||||
ZhixingB1Category.PULLBACK_YELLOW: "回踩黄线B",
|
||||
}
|
||||
|
||||
MINIMUM_HISTORY = 114
|
||||
|
||||
|
||||
def _not_big_green_bar(
|
||||
volume: np.ndarray,
|
||||
open_price: np.ndarray,
|
||||
close: np.ndarray,
|
||||
previous_close: np.ndarray,
|
||||
window: int = 40,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""Return ``(not_big_green, big_green_far)`` for every trading row."""
|
||||
|
||||
not_big_green = np.ones(len(volume), dtype=bool)
|
||||
big_green_far = np.zeros(len(volume), dtype=bool)
|
||||
for index in range(len(volume)):
|
||||
start = max(0, index - window + 1)
|
||||
trailing = volume[start : index + 1]
|
||||
finite = np.isfinite(trailing)
|
||||
if not finite.any():
|
||||
not_big_green[index] = False
|
||||
continue
|
||||
maximum = np.nanmax(trailing)
|
||||
local_positions = np.flatnonzero(finite & (trailing == maximum))
|
||||
maximum_index = start + int(local_positions[-1])
|
||||
periods_ago = index - maximum_index
|
||||
is_not_bearish = (
|
||||
close[maximum_index] >= previous_close[maximum_index]
|
||||
or close[maximum_index] >= open_price[maximum_index]
|
||||
)
|
||||
not_big_green[index] = is_not_bearish
|
||||
big_green_far[index] = not is_not_bearish and periods_ago >= 15
|
||||
index = pd.RangeIndex(len(volume))
|
||||
return (
|
||||
pd.Series(not_big_green, index=index),
|
||||
pd.Series(big_green_far, index=index),
|
||||
)
|
||||
|
||||
|
||||
def _safe_percentage(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
|
||||
"""Divide into percentages while making zero denominators explicit NaN."""
|
||||
|
||||
return numerator.div(denominator.replace(0, np.nan)).mul(100)
|
||||
|
||||
|
||||
def prepare_zhixing_b1_indicators(frame: pd.DataFrame, code: str) -> pd.DataFrame:
|
||||
"""Prepare all formula intermediates for ascending OHLCV rows.
|
||||
|
||||
Args:
|
||||
frame: DataFrame with ``open``, ``high``, ``low``, ``close`` and
|
||||
``volume`` columns, ordered from old to new.
|
||||
code: Tushare-style stock code used for width-limit parameters.
|
||||
|
||||
Returns:
|
||||
A copy containing named, testable intermediate formula values.
|
||||
|
||||
Raises:
|
||||
KeyError: If an OHLCV column is absent.
|
||||
"""
|
||||
|
||||
result = frame.copy()
|
||||
close = result["close"].astype(float)
|
||||
high = result["high"].astype(float)
|
||||
low = result["low"].astype(float)
|
||||
open_price = result["open"].astype(float)
|
||||
volume = result["volume"].astype(float)
|
||||
|
||||
white, yellow = compute_zhixing_lines(close)
|
||||
result["trend_white"] = white
|
||||
result["trend_yellow"] = yellow
|
||||
result["bbi"] = (MA(close, 3) + MA(close, 6) + MA(close, 12) + MA(close, 24)) / 4
|
||||
|
||||
short_low = LLV(low, 3)
|
||||
short_high = HHV(close, 3)
|
||||
long_low = LLV(low, 21)
|
||||
long_high = HHV(close, 21)
|
||||
result["short_oscillator"] = _safe_percentage(close - short_low, short_high - short_low)
|
||||
result["long_oscillator"] = _safe_percentage(close - long_low, long_high - long_low)
|
||||
|
||||
kdj = compute_kdj(result, window=9)
|
||||
result[["k", "d", "j"]] = kdj[["K", "D", "J"]]
|
||||
result["rsi"] = compute_rsi(close, window=3)
|
||||
|
||||
amplitude_range, relaxation = compute_amplitude_params(code, close)
|
||||
result["amplitude_range"] = amplitude_range
|
||||
result["relaxation"] = relaxation
|
||||
result["daily_amplitude"] = _safe_percentage(high - low, low)
|
||||
previous_close = REF(close, 1)
|
||||
result["daily_change"] = _safe_percentage((close - previous_close).abs(), previous_close)
|
||||
result["daily_change"] = result["daily_change"] * relaxation
|
||||
result["up_cross"] = (close > previous_close) & (
|
||||
_safe_percentage((close - open_price).abs(), open_price) * relaxation < 1.8
|
||||
)
|
||||
|
||||
highest_volume_20 = HHV(volume, 20)
|
||||
highest_volume_30 = HHV(volume, 30)
|
||||
highest_volume_50 = HHV(volume, 50)
|
||||
result["low_volume"] = (volume < highest_volume_20 * 0.416) | (volume < highest_volume_50 / 3)
|
||||
result["pullback_low_volume"] = (volume < highest_volume_20 * 0.45) | (
|
||||
volume < highest_volume_50 / 3
|
||||
)
|
||||
result["moderate_low_volume"] = (volume < highest_volume_20 * 0.618) | (
|
||||
volume < highest_volume_50 / 3
|
||||
)
|
||||
result["extreme_low_volume"] = (volume < highest_volume_30 / 4) | (
|
||||
volume < highest_volume_50 / 6
|
||||
)
|
||||
|
||||
not_big_green, big_green_far = _not_big_green_bar(
|
||||
volume.to_numpy(float),
|
||||
open_price.to_numpy(float),
|
||||
close.to_numpy(float),
|
||||
previous_close.fillna(close).to_numpy(float),
|
||||
)
|
||||
result["not_big_green"] = not_big_green.to_numpy()
|
||||
result["big_green_far"] = big_green_far.to_numpy()
|
||||
|
||||
recent_low = LLV(low, 20)
|
||||
recent_high = HHV(high, 20)
|
||||
distant_low = LLV(low, 50)
|
||||
distant_high = HHV(high, 50)
|
||||
result["recent_amplitude"] = _safe_percentage(recent_high - recent_low, recent_low)
|
||||
result["distant_amplitude"] = _safe_percentage(distant_high - distant_low, distant_low)
|
||||
result["super_change"] = result["recent_amplitude"] >= 60
|
||||
|
||||
short = result["short_oscillator"]
|
||||
long = result["long_oscillator"]
|
||||
result["single_pin"] = (short <= 20) & (long >= 75) | ((long - short) >= 70)
|
||||
result["treasure_bowl"] = (
|
||||
(COUNT(long >= 75, 8) >= 6) & (COUNT(short <= 70, 7) >= 4) & (COUNT(short <= 50, 8) >= 1)
|
||||
)
|
||||
result["double_trident"] = (
|
||||
EVERY(long >= 75, 8) & (COUNT(short <= 50, 6) >= 2) & (COUNT(short <= 20, 7) >= 1)
|
||||
)
|
||||
result["red_fat_green_thin"] = (COUNT(close >= open_price, 15) > 7) | (
|
||||
COUNT(close > previous_close, 11) > 5
|
||||
)
|
||||
result["wash_change"] = (
|
||||
(COUNT(result["single_pin"], 10) >= 2) | result["treasure_bowl"] | result["double_trident"]
|
||||
)
|
||||
result["recent_change"] = (result["recent_amplitude"] >= 15) | (
|
||||
_safe_percentage(HHV(high, 12) - LLV(low, 14), LLV(low, 14)) >= 11
|
||||
)
|
||||
result["distant_change"] = result["distant_amplitude"] >= 30
|
||||
|
||||
result["uptrend"] = (white >= yellow) & (
|
||||
(close >= yellow) | ((close > yellow * 0.975) & (close > open_price))
|
||||
)
|
||||
result["strong_trend"] = (
|
||||
EVERY(yellow >= REF(yellow, 1) * 0.999, 13)
|
||||
& (white >= REF(white, 1))
|
||||
& EVERY(white > yellow, 20)
|
||||
& EVERY(white >= REF(white, 1), 11)
|
||||
& result["red_fat_green_thin"]
|
||||
)
|
||||
result["super_bull"] = (
|
||||
(
|
||||
EVERY(result["bbi"] >= REF(result["bbi"], 1) * 0.999, 20)
|
||||
| (COUNT(result["bbi"] >= REF(result["bbi"], 1), 25) >= 23)
|
||||
)
|
||||
& ((result["recent_amplitude"] >= 30) | (result["distant_amplitude"] > 80))
|
||||
& (BARSLAST(CROSS(close, yellow)) > 12)
|
||||
)
|
||||
|
||||
result["white_distance"] = _safe_percentage((close - white).abs(), close)
|
||||
result["low_white_distance"] = _safe_percentage((low - white).abs(), white)
|
||||
result["bbi_distance"] = _safe_percentage((close - result["bbi"]).abs(), close)
|
||||
result["low_bbi_distance"] = _safe_percentage((low - result["bbi"]).abs(), result["bbi"])
|
||||
result["yellow_distance"] = _safe_percentage((close - yellow).abs(), yellow)
|
||||
result["white_pullback"] = (
|
||||
((close >= white) & (result["white_distance"] <= 2))
|
||||
| ((close < white) & (result["white_distance"] < 0.8))
|
||||
| (
|
||||
(close >= result["bbi"])
|
||||
& (result["bbi_distance"] < 2.5)
|
||||
& (result["low_bbi_distance"] < 1)
|
||||
& (result["white_distance"] <= 3)
|
||||
& (result["daily_change"] < 1)
|
||||
& (close > previous_close)
|
||||
)
|
||||
)
|
||||
result["white_support"] = (close >= white) & (result["white_distance"] < 1.5)
|
||||
result["strong_pullback"] = (
|
||||
((result["low_white_distance"] < 1) | (result["low_bbi_distance"] < 0.5))
|
||||
& (close > white)
|
||||
& (result["white_distance"] <= 3.5)
|
||||
)
|
||||
result["yellow_pullback"] = (
|
||||
(close >= yellow)
|
||||
& (
|
||||
(result["yellow_distance"] <= 1.5)
|
||||
| ((result["yellow_distance"] <= 2) & (result["daily_change"] < 1))
|
||||
)
|
||||
) | ((close < yellow) & (result["yellow_distance"] <= 0.8))
|
||||
return result
|
||||
|
||||
|
||||
def compute_signal_masks(frame: pd.DataFrame) -> dict[ZhixingB1Category, pd.Series]:
|
||||
"""Return all seven independent signal masks for prepared indicators.
|
||||
|
||||
The function deliberately returns every mask separately. Callers must
|
||||
not collapse them into one mask before constructing signals.
|
||||
"""
|
||||
|
||||
required = {
|
||||
"uptrend",
|
||||
"rsi",
|
||||
"j",
|
||||
"amplitude_range",
|
||||
"daily_amplitude",
|
||||
"daily_change",
|
||||
"up_cross",
|
||||
"not_big_green",
|
||||
"big_green_far",
|
||||
"recent_change",
|
||||
"distant_change",
|
||||
"wash_change",
|
||||
"trend_white",
|
||||
"trend_yellow",
|
||||
"low_volume",
|
||||
"moderate_low_volume",
|
||||
"extreme_low_volume",
|
||||
"recent_amplitude",
|
||||
"distant_amplitude",
|
||||
"super_change",
|
||||
"strong_trend",
|
||||
"super_bull",
|
||||
"white_distance",
|
||||
"bbi_distance",
|
||||
"yellow_distance",
|
||||
"white_pullback",
|
||||
"white_support",
|
||||
"strong_pullback",
|
||||
"yellow_pullback",
|
||||
"low_white_distance",
|
||||
"low_bbi_distance",
|
||||
"bbi",
|
||||
"open",
|
||||
"close",
|
||||
"low",
|
||||
"volume",
|
||||
}
|
||||
missing = sorted(required.difference(frame.columns))
|
||||
if missing:
|
||||
raise ValueError(f"prepared indicators missing columns: {', '.join(missing)}")
|
||||
|
||||
rsi = frame["rsi"]
|
||||
j = frame["j"]
|
||||
rsi_j = rsi + j
|
||||
previous_rsi = REF(rsi, 1)
|
||||
previous_j = REF(j, 1)
|
||||
previous_volume = REF(frame["volume"], 1)
|
||||
change_trigger = frame["recent_change"] | frame["distant_change"] | frame["wash_change"]
|
||||
not_green = frame["not_big_green"] | frame["big_green_far"]
|
||||
daily_range = frame["daily_amplitude"]
|
||||
daily_change = frame["daily_change"]
|
||||
close = frame["close"]
|
||||
open_price = frame["open"]
|
||||
|
||||
oversold_turn = (
|
||||
frame["uptrend"]
|
||||
& ((rsi - 15) >= previous_rsi)
|
||||
& ((previous_rsi < 20) | (previous_j < 14))
|
||||
& (daily_range < frame["amplitude_range"] + 0.5)
|
||||
& ((daily_change < 2.3) | (frame["up_cross"] & (daily_change < 4)))
|
||||
& not_green
|
||||
& change_trigger
|
||||
& (close >= frame["trend_yellow"])
|
||||
)
|
||||
oversold_volume = (
|
||||
frame["uptrend"]
|
||||
& ((j < 14) | (rsi < 23))
|
||||
& ((rsi_j < 55) | (j == LLV(j, 20)))
|
||||
& (daily_range < frame["amplitude_range"])
|
||||
& ((daily_change < 2.5) | frame["up_cross"])
|
||||
& not_green
|
||||
& (frame["low_volume"] | (frame["moderate_low_volume"] & (daily_change < 1)))
|
||||
& change_trigger
|
||||
)
|
||||
original_b1 = (
|
||||
(frame["trend_white"] > frame["trend_yellow"])
|
||||
& (close >= frame["trend_yellow"] * 0.99)
|
||||
& (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1))
|
||||
& ((j < 13) | (rsi < 21))
|
||||
& (rsi_j < LLV(rsi_j, 15) * 1.5)
|
||||
& frame["moderate_low_volume"]
|
||||
& not_green
|
||||
& (
|
||||
(_safe_percentage((close - open_price).abs(), open_price) < 1.5)
|
||||
| frame["extreme_low_volume"]
|
||||
| (
|
||||
frame["moderate_low_volume"]
|
||||
& (frame["volume"] < LLV(frame["volume"], 20) * 1.1)
|
||||
& (j == LLV(j, 20))
|
||||
)
|
||||
| (
|
||||
frame["moderate_low_volume"]
|
||||
& (
|
||||
(frame["white_distance"] < 1.8)
|
||||
| (frame["bbi_distance"] < 1.5)
|
||||
| (frame["yellow_distance"] < 2.8)
|
||||
)
|
||||
)
|
||||
)
|
||||
& change_trigger
|
||||
)
|
||||
extreme_volume = (
|
||||
frame["uptrend"]
|
||||
& ((j < 14) | (rsi < 23))
|
||||
& (rsi_j < 60)
|
||||
& (frame["distant_amplitude"] >= 45)
|
||||
& (
|
||||
(daily_range < frame["amplitude_range"])
|
||||
| (
|
||||
frame["super_change"]
|
||||
& (daily_range < frame["amplitude_range"] + 3.2)
|
||||
& (close > open_price)
|
||||
& (close > frame["trend_white"])
|
||||
)
|
||||
)
|
||||
& (
|
||||
(
|
||||
(close < open_price)
|
||||
& (frame["volume"] < previous_volume)
|
||||
& (close >= frame["trend_yellow"])
|
||||
)
|
||||
| (close >= open_price)
|
||||
)
|
||||
& ((daily_change < 2) | frame["up_cross"])
|
||||
& not_green
|
||||
& frame["extreme_low_volume"]
|
||||
& change_trigger
|
||||
)
|
||||
pullback_white = (
|
||||
frame["strong_trend"]
|
||||
& ((j < 30) | (rsi < 40) | frame["wash_change"])
|
||||
& (rsi_j < 70)
|
||||
& (
|
||||
(daily_range < frame["amplitude_range"] + 0.5)
|
||||
| (frame["white_distance"] < 1)
|
||||
| (frame["bbi_distance"] < 1)
|
||||
)
|
||||
& frame["white_pullback"]
|
||||
& ((daily_change < 2) | ((daily_change < 5) & frame["white_support"]))
|
||||
& not_green
|
||||
& frame["pullback_low_volume"]
|
||||
& change_trigger
|
||||
& (frame["low"] <= REF(close, 1))
|
||||
)
|
||||
pullback_super = (
|
||||
frame["super_bull"]
|
||||
& ((j < 35) | (rsi < 45) | frame["wash_change"])
|
||||
& (rsi_j < 80)
|
||||
& (rsi_j == LLV(rsi_j, 25))
|
||||
& (daily_range < frame["amplitude_range"] + 1)
|
||||
& ((daily_change < 2.5) | (frame["white_distance"] < 2))
|
||||
& frame["strong_pullback"]
|
||||
& not_green
|
||||
& change_trigger
|
||||
& frame["moderate_low_volume"]
|
||||
)
|
||||
pullback_yellow = (
|
||||
(frame["trend_white"] >= frame["trend_yellow"])
|
||||
& (close >= frame["trend_yellow"] * 0.975)
|
||||
& ((j < 13) | (rsi < 18))
|
||||
& frame["yellow_pullback"]
|
||||
& not_green
|
||||
& (
|
||||
frame["low_volume"]
|
||||
| (frame["moderate_low_volume"] & ((j == LLV(j, 20)) | (rsi == LLV(rsi, 14))))
|
||||
)
|
||||
& (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1) * 0.997)
|
||||
& (MA(close, 60) >= REF(MA(close, 60), 1))
|
||||
& (frame["recent_amplitude"] >= 11.9)
|
||||
& (frame["distant_amplitude"] >= 19.5)
|
||||
)
|
||||
return {
|
||||
ZhixingB1Category.OVERSOLD_TURN: oversold_turn.fillna(False).astype(bool),
|
||||
ZhixingB1Category.OVERSOLD_VOLUME: oversold_volume.fillna(False).astype(bool),
|
||||
ZhixingB1Category.ORIGINAL_B1: original_b1.fillna(False).astype(bool),
|
||||
ZhixingB1Category.EXTREME_VOLUME: extreme_volume.fillna(False).astype(bool),
|
||||
ZhixingB1Category.PULLBACK_WHITE: pullback_white.fillna(False).astype(bool),
|
||||
ZhixingB1Category.PULLBACK_SUPER: pullback_super.fillna(False).astype(bool),
|
||||
ZhixingB1Category.PULLBACK_YELLOW: pullback_yellow.fillna(False).astype(bool),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ZhixingB1Strategy:
|
||||
"""Evaluate all seven B1 sub-signals for a specified historical date."""
|
||||
|
||||
name: str = "zhixing_b1"
|
||||
|
||||
def evaluate(self, history: StockHistory, target_trade_date: date) -> SelectionEvaluation:
|
||||
"""Return selected, no-signal, warm-up, or missing-target state.
|
||||
|
||||
Only bars through ``target_trade_date`` are passed into the formulas;
|
||||
future rows supplied by a reader cannot affect the historical result.
|
||||
"""
|
||||
|
||||
bars_by_date = {bar.trade_date: bar for bar in history.bars}
|
||||
target_bar = bars_by_date.get(target_trade_date)
|
||||
if target_bar is None or any(
|
||||
value is None
|
||||
for value in (
|
||||
target_bar.open,
|
||||
target_bar.high,
|
||||
target_bar.low,
|
||||
target_bar.close,
|
||||
target_bar.volume,
|
||||
)
|
||||
):
|
||||
return SelectionEvaluation(
|
||||
history.ts_code,
|
||||
target_trade_date,
|
||||
"missing_target_bar",
|
||||
reason="target trade date has no complete qfq daily bar",
|
||||
)
|
||||
|
||||
selected_bars = tuple(
|
||||
sorted(
|
||||
(bar for bar in bars_by_date.values() if bar.trade_date <= target_trade_date),
|
||||
key=lambda bar: bar.trade_date,
|
||||
)
|
||||
)
|
||||
if len(selected_bars) < MINIMUM_HISTORY:
|
||||
return SelectionEvaluation(
|
||||
history.ts_code,
|
||||
target_trade_date,
|
||||
"insufficient_history",
|
||||
reason=f"need at least {MINIMUM_HISTORY} ascending bars before evaluation",
|
||||
)
|
||||
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"trade_date": [bar.trade_date for bar in selected_bars],
|
||||
"open": [bar.open for bar in selected_bars],
|
||||
"high": [bar.high for bar in selected_bars],
|
||||
"low": [bar.low for bar in selected_bars],
|
||||
"close": [bar.close for bar in selected_bars],
|
||||
"volume": [bar.volume for bar in selected_bars],
|
||||
}
|
||||
)
|
||||
if bool(frame.isna().to_numpy().any()):
|
||||
target_index = frame.index[frame["trade_date"] == target_trade_date]
|
||||
target_incomplete = False
|
||||
if not target_index.empty:
|
||||
target_incomplete = bool(
|
||||
frame.loc[target_index[0], ["open", "high", "low", "close", "volume"]]
|
||||
.isna()
|
||||
.to_numpy()
|
||||
.any()
|
||||
)
|
||||
if target_index.empty or target_incomplete:
|
||||
return SelectionEvaluation(
|
||||
history.ts_code,
|
||||
target_trade_date,
|
||||
"missing_target_bar",
|
||||
reason="target trade date has incomplete qfq OHLCV values",
|
||||
)
|
||||
|
||||
prepared = prepare_zhixing_b1_indicators(frame, history.ts_code)
|
||||
masks = compute_signal_masks(prepared)
|
||||
target_index = int(prepared.index[prepared["trade_date"] == target_trade_date][0])
|
||||
matched = tuple(
|
||||
category
|
||||
for category in ZHIXING_B1_SIGNAL_ORDER
|
||||
if bool(masks[category].iloc[target_index])
|
||||
)
|
||||
if not matched:
|
||||
return SelectionEvaluation(
|
||||
history.ts_code,
|
||||
target_trade_date,
|
||||
"no_signal",
|
||||
reason="no Zhixing B1 sub-signal matched the target row",
|
||||
)
|
||||
|
||||
row = prepared.iloc[target_index]
|
||||
details = serializable_metrics(
|
||||
(
|
||||
("sub_signal", ";".join(_SIGNAL_LABELS[category] for category in matched)),
|
||||
("j", row["j"]),
|
||||
("rsi", row["rsi"]),
|
||||
("trend_white", row["trend_white"]),
|
||||
("trend_yellow", row["trend_yellow"]),
|
||||
("daily_amplitude", row["daily_amplitude"]),
|
||||
("daily_change", row["daily_change"]),
|
||||
("volume", row["volume"]),
|
||||
)
|
||||
)
|
||||
signals = tuple(
|
||||
SelectionSignal(
|
||||
ts_code=history.ts_code,
|
||||
name=history.name,
|
||||
target_trade_date=target_trade_date,
|
||||
strategy="zhixing_b1",
|
||||
category=category,
|
||||
close=float(row["close"]),
|
||||
details=details,
|
||||
)
|
||||
for category in matched
|
||||
)
|
||||
return SelectionEvaluation(
|
||||
history.ts_code,
|
||||
target_trade_date,
|
||||
"selected",
|
||||
signals=signals,
|
||||
)
|
||||
|
||||
def select(self, history: StockHistory, target_trade_date: date) -> tuple[SelectionSignal, ...]:
|
||||
"""Return only signals for callers that do not need evaluation status."""
|
||||
|
||||
return self.evaluate(history, target_trade_date).signals
|
||||
@@ -0,0 +1 @@
|
||||
"""Selection infrastructure adapters."""
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
"""Read-only PostgreSQL adapter for selection history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import cast
|
||||
|
||||
import psycopg
|
||||
|
||||
from ....bootstrap.config import Settings
|
||||
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
|
||||
from ..domain.ports import MarketDataReaderError
|
||||
|
||||
|
||||
class SelectionReaderError(MarketDataReaderError):
|
||||
"""Database read failure with stock and target-date context."""
|
||||
|
||||
|
||||
_HISTORY_QUERY = """
|
||||
SELECT
|
||||
bar.ts_code,
|
||||
stock.name,
|
||||
bar.trade_date,
|
||||
bar.open,
|
||||
bar.high,
|
||||
bar.low,
|
||||
bar.close,
|
||||
bar.vol,
|
||||
basic.turnover_rate,
|
||||
basic.total_mv
|
||||
FROM market_daily_bar AS bar
|
||||
LEFT JOIN market_stock AS stock
|
||||
ON stock.ts_code = bar.ts_code
|
||||
LEFT JOIN market_daily_basic AS basic
|
||||
ON basic.ts_code = bar.ts_code
|
||||
AND basic.trade_date = bar.trade_date
|
||||
WHERE bar.ts_code = %s
|
||||
AND bar.source_adj = 'qfq'
|
||||
AND bar.trade_date <= %s
|
||||
ORDER BY bar.trade_date ASC
|
||||
"""
|
||||
|
||||
|
||||
def _as_date(value: object) -> date:
|
||||
"""Convert a PostgreSQL date-like scalar to a date."""
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""Convert nullable PostgreSQL numerics to finite floats."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise ValueError(f"invalid market-data numeric value: {value!r}") from exc
|
||||
if number.is_nan():
|
||||
return None
|
||||
if not number.is_finite():
|
||||
raise ValueError(f"market-data numeric value must be finite: {value!r}")
|
||||
return float(number)
|
||||
|
||||
|
||||
class PostgresMarketDataReader:
|
||||
"""Load qfq bars and same-day basic facts without writing market data."""
|
||||
|
||||
def __init__(self, settings: Settings | str) -> None:
|
||||
"""Create a reader from injected settings or a compatible URL string."""
|
||||
|
||||
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
|
||||
|
||||
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
|
||||
"""Read all retained qfq rows through the explicit target date.
|
||||
|
||||
Args:
|
||||
ts_code: Tushare stock identifier.
|
||||
target_trade_date: Historical date to which rows are truncated.
|
||||
|
||||
Returns:
|
||||
A sorted ``StockHistory``. An empty history is a normal missing
|
||||
target-data result and is interpreted by the application layer.
|
||||
|
||||
Raises:
|
||||
SelectionReaderError: If PostgreSQL cannot complete the read.
|
||||
ValueError: If a returned date or numeric field is malformed.
|
||||
"""
|
||||
|
||||
try:
|
||||
with psycopg.connect(self.database_url) as connection:
|
||||
rows = connection.execute(
|
||||
_HISTORY_QUERY,
|
||||
(ts_code, target_trade_date),
|
||||
).fetchall()
|
||||
except psycopg.Error as exc:
|
||||
raise SelectionReaderError(
|
||||
f"failed to load market history for {ts_code} at {target_trade_date.isoformat()}"
|
||||
) from exc
|
||||
|
||||
bars: dict[date, SelectionBar] = {}
|
||||
daily_basic: dict[date, SelectionDailyBasic] = {}
|
||||
name = ""
|
||||
for raw_row in rows:
|
||||
row = cast(tuple[object, ...], raw_row)
|
||||
row_code, row_name, bar, basic = self._map_row(row, ts_code)
|
||||
if row_code != ts_code:
|
||||
raise ValueError(f"reader returned unexpected stock code: {row_code}")
|
||||
name = row_name or name
|
||||
if bar.trade_date <= target_trade_date:
|
||||
bars[bar.trade_date] = bar
|
||||
daily_basic[bar.trade_date] = basic
|
||||
return StockHistory(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
bars=tuple(bars[trade_date] for trade_date in sorted(bars)),
|
||||
daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_row(
|
||||
row: tuple[object, ...],
|
||||
expected_code: str,
|
||||
) -> tuple[str, str, SelectionBar, SelectionDailyBasic]:
|
||||
"""Map the current query row, tolerating a legacy test row without name."""
|
||||
|
||||
if len(row) >= 10:
|
||||
code, raw_name, raw_date = row[0], row[1], row[2]
|
||||
values = row[3:]
|
||||
elif len(row) >= 9:
|
||||
code, raw_name, raw_date = row[0], "", row[1]
|
||||
values = row[2:]
|
||||
else:
|
||||
raise ValueError("market history row has too few columns")
|
||||
row_code = str(code or expected_code)
|
||||
name = str(raw_name or "")
|
||||
trade_date = _as_date(raw_date)
|
||||
if len(values) < 7:
|
||||
raise ValueError("market history row is missing OHLCV/basic columns")
|
||||
bar = SelectionBar(
|
||||
trade_date=trade_date,
|
||||
open=_as_float(values[0]),
|
||||
high=_as_float(values[1]),
|
||||
low=_as_float(values[2]),
|
||||
close=_as_float(values[3]),
|
||||
volume=_as_float(values[4]),
|
||||
)
|
||||
basic = SelectionDailyBasic(
|
||||
trade_date=trade_date,
|
||||
turnover_rate=_as_float(values[5]),
|
||||
total_mv=_as_float(values[6]),
|
||||
)
|
||||
return row_code, name, bar, basic
|
||||
@@ -0,0 +1 @@
|
||||
"""Transport adapters for selection; intentionally empty in the first slice."""
|
||||
Reference in New Issue
Block a user