feat(selection): 集成 B1 FastDTW 图形评分
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""Add stock-level versioned B1 pattern scoring results."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004_selection_pattern_scoring"
|
||||
down_revision: str | None = "0003_market_integrity_checks"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_PATTERN_BREAKDOWN_CHECK = """
|
||||
match_breakdown IS NULL OR (
|
||||
jsonb_typeof(match_breakdown) = 'object'
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number'
|
||||
THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number'
|
||||
THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number'
|
||||
THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number'
|
||||
THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add nullable scoring data while keeping existing runs readable."""
|
||||
|
||||
op.add_column(
|
||||
"selection_run_item",
|
||||
sa.Column(
|
||||
"score_status",
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
server_default="not_executed",
|
||||
),
|
||||
)
|
||||
op.add_column("selection_run_item", sa.Column("score_value", sa.Numeric(5, 2)))
|
||||
op.add_column("selection_run_item", sa.Column("score_threshold", sa.Numeric(5, 2)))
|
||||
op.add_column("selection_run_item", sa.Column("score_version", sa.String(64)))
|
||||
op.add_column("selection_run_item", sa.Column("match_case_id", sa.String(32)))
|
||||
op.add_column("selection_run_item", sa.Column("match_case_name", sa.String(128)))
|
||||
op.add_column("selection_run_item", sa.Column("match_case_breakout_date", sa.Date()))
|
||||
op.add_column(
|
||||
"selection_run_item",
|
||||
sa.Column("match_breakdown", postgresql.JSONB(astext_type=sa.Text())),
|
||||
)
|
||||
op.add_column("selection_run_item", sa.Column("score_reason", sa.Text()))
|
||||
op.create_check_constraint(
|
||||
"ck_selection_run_item_score_status",
|
||||
"selection_run_item",
|
||||
"score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_selection_run_item_score_value_range",
|
||||
"selection_run_item",
|
||||
"score_value IS NULL OR score_value BETWEEN 0 AND 100",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_selection_run_item_score_threshold_range",
|
||||
"selection_run_item",
|
||||
"score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_selection_run_item_breakdown_range",
|
||||
"selection_run_item",
|
||||
_PATTERN_BREAKDOWN_CHECK,
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_selection_run_item_score_shape",
|
||||
"selection_run_item",
|
||||
"(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL "
|
||||
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
|
||||
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
|
||||
"AND score_reason IS NULL) "
|
||||
"OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL "
|
||||
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
|
||||
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
|
||||
"AND score_reason IS NOT NULL) "
|
||||
"OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL "
|
||||
"AND score_threshold IS NOT NULL AND score_version IS NOT NULL "
|
||||
"AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL "
|
||||
"AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL "
|
||||
"AND score_reason IS NULL "
|
||||
"AND ((score_status = 'matched' AND score_value >= score_threshold) "
|
||||
"OR (score_status = 'below_threshold' AND score_value < score_threshold)))",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_selection_run_item_score",
|
||||
"selection_run_item",
|
||||
["run_id", sa.text("score_value DESC"), "ts_code"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove only the additive stock-level scoring contract."""
|
||||
|
||||
op.drop_index("ix_selection_run_item_score", table_name="selection_run_item")
|
||||
op.drop_constraint(
|
||||
"ck_selection_run_item_score_shape",
|
||||
"selection_run_item",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_selection_run_item_score_threshold_range",
|
||||
"selection_run_item",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_selection_run_item_breakdown_range",
|
||||
"selection_run_item",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_selection_run_item_score_value_range",
|
||||
"selection_run_item",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_selection_run_item_score_status",
|
||||
"selection_run_item",
|
||||
type_="check",
|
||||
)
|
||||
for column in (
|
||||
"score_reason",
|
||||
"match_breakdown",
|
||||
"match_case_breakout_date",
|
||||
"match_case_name",
|
||||
"match_case_id",
|
||||
"score_version",
|
||||
"score_threshold",
|
||||
"score_value",
|
||||
"score_status",
|
||||
):
|
||||
op.drop_column("selection_run_item", column)
|
||||
@@ -7,6 +7,7 @@ requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.18.0",
|
||||
"fastapi>=0.141.1",
|
||||
"fastdtw>=0.3.4",
|
||||
"numpy>=2.4.0",
|
||||
"pandas>=2.3.3",
|
||||
"psycopg[binary,pool]>=3.3.2",
|
||||
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
market_data_advisory_lock_key: int = 7_380_521
|
||||
selection_max_workers: int = Field(default=4, ge=1)
|
||||
selection_batch_size: int = Field(default=200, ge=1)
|
||||
selection_pattern_scoring_enabled: bool = True
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
@@ -22,6 +23,24 @@ from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
_PATTERN_BREAKDOWN_CHECK = """
|
||||
match_breakdown IS NULL OR (
|
||||
jsonb_typeof(match_breakdown) = 'object'
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number'
|
||||
THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number'
|
||||
THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number'
|
||||
THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number'
|
||||
THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100
|
||||
ELSE false END
|
||||
)
|
||||
"""
|
||||
|
||||
market_stock = Table(
|
||||
"market_stock",
|
||||
metadata,
|
||||
@@ -153,8 +172,51 @@ selection_run_item = Table(
|
||||
Column("status", String(32), nullable=False),
|
||||
Column("signal_count", Integer, nullable=False, server_default="0"),
|
||||
Column("reason", Text),
|
||||
Column("score_status", String(32), nullable=False, server_default="not_executed"),
|
||||
Column("score_value", Numeric(5, 2)),
|
||||
Column("score_threshold", Numeric(5, 2)),
|
||||
Column("score_version", String(64)),
|
||||
Column("match_case_id", String(32)),
|
||||
Column("match_case_name", String(128)),
|
||||
Column("match_case_breakout_date", Date),
|
||||
Column("match_breakdown", JSONB),
|
||||
Column("score_reason", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
PrimaryKeyConstraint("run_id", "ts_code"),
|
||||
CheckConstraint(
|
||||
"score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')",
|
||||
name="ck_selection_run_item_score_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"score_value IS NULL OR score_value BETWEEN 0 AND 100",
|
||||
name="ck_selection_run_item_score_value_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100",
|
||||
name="ck_selection_run_item_score_threshold_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
_PATTERN_BREAKDOWN_CHECK,
|
||||
name="ck_selection_run_item_breakdown_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL "
|
||||
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
|
||||
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
|
||||
"AND score_reason IS NULL) "
|
||||
"OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL "
|
||||
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
|
||||
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
|
||||
"AND score_reason IS NOT NULL) "
|
||||
"OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL "
|
||||
"AND score_threshold IS NOT NULL AND score_version IS NOT NULL "
|
||||
"AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL "
|
||||
"AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL "
|
||||
"AND score_reason IS NULL "
|
||||
"AND ((score_status = 'matched' AND score_value >= score_threshold) "
|
||||
"OR (score_status = 'below_threshold' AND score_value < score_threshold)))",
|
||||
name="ck_selection_run_item_score_shape",
|
||||
),
|
||||
)
|
||||
|
||||
selection_signal = Table(
|
||||
@@ -223,6 +285,12 @@ Index(
|
||||
selection_run.c.target_trade_date,
|
||||
)
|
||||
Index("ix_selection_run_item_status", selection_run_item.c.run_id, selection_run_item.c.status)
|
||||
Index(
|
||||
"ix_selection_run_item_score",
|
||||
selection_run_item.c.run_id,
|
||||
selection_run_item.c.score_value.desc(),
|
||||
selection_run_item.c.ts_code,
|
||||
)
|
||||
Index(
|
||||
"ix_selection_signal_strategy_date",
|
||||
selection_signal.c.strategy,
|
||||
|
||||
@@ -11,6 +11,12 @@ from datetime import date
|
||||
from typing import Literal, Protocol, cast
|
||||
|
||||
from ..domain.models import SelectionEvaluation, StockHistory
|
||||
from ..domain.pattern_scoring import (
|
||||
PatternCase,
|
||||
PatternCaseLibraryLoader,
|
||||
PatternScore,
|
||||
PatternScorer,
|
||||
)
|
||||
from ..domain.runs import (
|
||||
BatchSelectionRunStore,
|
||||
BatchSelectionUniverseReader,
|
||||
@@ -54,7 +60,10 @@ class RunZhixingB1:
|
||||
reader: SelectionUniverseReader,
|
||||
store: SelectionRunStore,
|
||||
evaluator: SelectionEvaluator | None = None,
|
||||
pattern_case_loader: PatternCaseLibraryLoader | None = None,
|
||||
pattern_scorer: PatternScorer | None = None,
|
||||
*,
|
||||
pattern_scoring_enabled: bool = False,
|
||||
max_workers: int = 4,
|
||||
batch_size: int = 200,
|
||||
) -> None:
|
||||
@@ -67,6 +76,9 @@ class RunZhixingB1:
|
||||
self.reader = reader
|
||||
self.store = store
|
||||
self.evaluator = evaluator or EvaluateZhixingB1(reader)
|
||||
self.pattern_case_loader = pattern_case_loader
|
||||
self.pattern_scorer = pattern_scorer
|
||||
self.pattern_scoring_enabled = pattern_scoring_enabled
|
||||
self.max_workers = max_workers
|
||||
self.batch_size = batch_size
|
||||
|
||||
@@ -106,7 +118,9 @@ class RunZhixingB1:
|
||||
read_seconds = 0.0
|
||||
evaluate_seconds = 0.0
|
||||
persist_seconds = 0.0
|
||||
scoring_seconds = 0.0
|
||||
try:
|
||||
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
for batch_stocks in _chunks(stocks, self.batch_size):
|
||||
read_started = time.perf_counter()
|
||||
@@ -120,24 +134,39 @@ class RunZhixingB1:
|
||||
)
|
||||
|
||||
evaluate_started = time.perf_counter()
|
||||
evaluations = tuple(
|
||||
executor.map(
|
||||
self._evaluate_stock,
|
||||
batch_stocks,
|
||||
histories,
|
||||
[prepared.source.target_trade_date] * len(batch_stocks),
|
||||
)
|
||||
)
|
||||
evaluate_seconds += time.perf_counter() - evaluate_started
|
||||
|
||||
scoring_started = time.perf_counter()
|
||||
items = tuple(
|
||||
_to_item(
|
||||
stock.ts_code,
|
||||
stock.name,
|
||||
evaluation,
|
||||
)
|
||||
for stock, evaluation in zip(
|
||||
batch_stocks,
|
||||
executor.map(
|
||||
self._evaluate_stock,
|
||||
batch_stocks,
|
||||
histories,
|
||||
[prepared.source.target_trade_date] * len(batch_stocks),
|
||||
pattern_score=self._score_stock(
|
||||
prepared.run.id,
|
||||
stock,
|
||||
history,
|
||||
evaluation,
|
||||
pattern_cases,
|
||||
pattern_library_error,
|
||||
),
|
||||
)
|
||||
for stock, history, evaluation in zip(
|
||||
batch_stocks,
|
||||
histories,
|
||||
evaluations,
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
evaluate_seconds += time.perf_counter() - evaluate_started
|
||||
scoring_seconds += time.perf_counter() - scoring_started
|
||||
|
||||
evaluated_count += len(items)
|
||||
selected_stock_count += sum(item.status == "selected" for item in items)
|
||||
@@ -184,7 +213,7 @@ class RunZhixingB1:
|
||||
logger.info(
|
||||
"selection_run_summary run_id=%s stock_count=%d history_rows=%d "
|
||||
"batch_count=%d worker_count=%d read_seconds=%.3f "
|
||||
"evaluate_seconds=%.3f persist_seconds=%.3f",
|
||||
"evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f",
|
||||
prepared.run.id,
|
||||
len(stocks),
|
||||
history_rows,
|
||||
@@ -192,9 +221,64 @@ class RunZhixingB1:
|
||||
self.max_workers,
|
||||
read_seconds,
|
||||
evaluate_seconds,
|
||||
scoring_seconds,
|
||||
persist_seconds,
|
||||
)
|
||||
|
||||
def _prepare_pattern_cases(
|
||||
self,
|
||||
run_id: str,
|
||||
) -> tuple[tuple[PatternCase, ...] | None, str | None]:
|
||||
"""Load the complete case library once without failing selection."""
|
||||
|
||||
if not self.pattern_scoring_enabled:
|
||||
return None, None
|
||||
if self.pattern_case_loader is None or self.pattern_scorer is None:
|
||||
reason = "pattern scoring is enabled but not configured"
|
||||
logger.error("selection_pattern_library_failed run_id=%s reason=%s", run_id, reason)
|
||||
return None, reason
|
||||
try:
|
||||
return self.pattern_case_loader.load(), None
|
||||
except Exception as exc: # noqa: BLE001 - scoring enrichment must not fail selection
|
||||
reason = _safe_item_error(exc)
|
||||
logger.warning(
|
||||
"selection_pattern_library_failed run_id=%s error_type=%s reason=%s",
|
||||
run_id,
|
||||
exc.__class__.__name__,
|
||||
reason,
|
||||
)
|
||||
return None, reason
|
||||
|
||||
def _score_stock(
|
||||
self,
|
||||
run_id: str,
|
||||
stock: SelectionStock,
|
||||
history: StockHistory | None,
|
||||
evaluation: SelectionEvaluation,
|
||||
cases: tuple[PatternCase, ...] | None,
|
||||
library_error: str | None,
|
||||
) -> PatternScore:
|
||||
"""Score one selected stock once and isolate enrichment failures."""
|
||||
|
||||
if not self.pattern_scoring_enabled or evaluation.status != "selected":
|
||||
return PatternScore()
|
||||
if library_error is not None:
|
||||
return PatternScore.failed(library_error)
|
||||
if history is None or cases is None or self.pattern_scorer is None:
|
||||
return PatternScore.failed("pattern scoring history or case library is unavailable")
|
||||
try:
|
||||
return self.pattern_scorer.score(history, cases)
|
||||
except Exception as exc: # noqa: BLE001 - one score must not fail the selection run
|
||||
reason = _safe_item_error(exc)
|
||||
logger.warning(
|
||||
"selection_pattern_score_failed run_id=%s ts_code=%s error_type=%s reason=%s",
|
||||
run_id,
|
||||
stock.ts_code,
|
||||
exc.__class__.__name__,
|
||||
reason,
|
||||
)
|
||||
return PatternScore.failed(reason)
|
||||
|
||||
def _load_histories(
|
||||
self,
|
||||
stocks: Sequence[SelectionStock],
|
||||
@@ -287,7 +371,13 @@ class RunZhixingB1:
|
||||
return self.store.get_latest_run(strategy, target_trade_date, query=query)
|
||||
|
||||
|
||||
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
|
||||
def _to_item(
|
||||
ts_code: str,
|
||||
name: str,
|
||||
evaluation: SelectionEvaluation,
|
||||
*,
|
||||
pattern_score: PatternScore | None = None,
|
||||
) -> SelectionRunItem:
|
||||
"""Translate a single-stock domain result into a stored item."""
|
||||
|
||||
return SelectionRunItem(
|
||||
@@ -296,6 +386,7 @@ def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> Select
|
||||
status=evaluation.status,
|
||||
signal_count=len(evaluation.signals),
|
||||
reason=evaluation.reason,
|
||||
pattern_score=pattern_score or PatternScore(),
|
||||
signals=evaluation.signals,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
"""Versioned Zhixing B1 pattern-similarity scoring.
|
||||
|
||||
The module deliberately keeps the algorithm and its ten case definitions in
|
||||
one bounded-context-owned contract. Infrastructure supplies qfq histories;
|
||||
the scorer performs no I/O and never falls back to a different DTW algorithm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from math import isfinite
|
||||
from numbers import Real
|
||||
from typing import Literal, Protocol, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from fastdtw import fastdtw # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
from .models import SelectionBar, StockHistory
|
||||
|
||||
PATTERN_SCORING_VERSION = "zhixing_b1_pattern_fastdtw_v1"
|
||||
PATTERN_LOOKBACK_DAYS = 25
|
||||
PATTERN_SCORE_THRESHOLD = 60.0
|
||||
PATTERN_FASTDTW_RADIUS = 1
|
||||
PATTERN_WEIGHTS = {
|
||||
"trend_structure": 0.10,
|
||||
"kdj_state": 0.20,
|
||||
"volume_pattern": 0.25,
|
||||
"price_shape": 0.45,
|
||||
}
|
||||
PATTERN_TOLERANCES = {
|
||||
"trend_ratio": 0.10,
|
||||
"price_bias": 10.0,
|
||||
"trend_spread": 10.0,
|
||||
"j_value": 30.0,
|
||||
"drawdown": 15.0,
|
||||
}
|
||||
|
||||
PatternScoreStatus = Literal["not_executed", "matched", "below_threshold", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PatternCaseDefinition:
|
||||
"""A versioned pattern template and its exclusive breakout boundary."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
ts_code: str
|
||||
breakout_date: date
|
||||
lookback_days: int = PATTERN_LOOKBACK_DAYS
|
||||
|
||||
|
||||
ZHIXING_B1_PATTERN_CASES: tuple[PatternCaseDefinition, ...] = (
|
||||
PatternCaseDefinition("case_001", "华纳药厂", "688799.SH", date(2025, 5, 12)),
|
||||
PatternCaseDefinition("case_002", "宁波韵升", "600366.SH", date(2025, 8, 6)),
|
||||
PatternCaseDefinition("case_003", "微芯生物", "688321.SH", date(2025, 6, 20)),
|
||||
PatternCaseDefinition("case_004", "方正科技", "600601.SH", date(2025, 7, 23)),
|
||||
PatternCaseDefinition("case_006", "国轩高科", "002074.SZ", date(2025, 8, 4)),
|
||||
PatternCaseDefinition("case_007", "野马电池", "605378.SH", date(2025, 8, 1)),
|
||||
PatternCaseDefinition("case_008", "光电股份", "600184.SH", date(2025, 7, 10)),
|
||||
PatternCaseDefinition("case_009", "新瀚新材", "301076.SZ", date(2025, 8, 1)),
|
||||
PatternCaseDefinition("case_010", "昂利康", "002940.SZ", date(2025, 7, 11)),
|
||||
PatternCaseDefinition("case_011", "航天发展", "000547.SZ", date(2025, 11, 12)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PatternFeatures:
|
||||
"""Immutable, finite-or-null features used by the matcher."""
|
||||
|
||||
trend_structure: Mapping[str, float | bool | None]
|
||||
kdj_state: Mapping[str, float | bool | str | None]
|
||||
volume_pattern: Mapping[str, float | bool | str | int | None]
|
||||
price_shape: Mapping[str, float | str | int | tuple[float, ...] | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PatternCase:
|
||||
"""One complete case history with precomputed immutable features."""
|
||||
|
||||
definition: PatternCaseDefinition
|
||||
history: StockHistory
|
||||
features: PatternFeatures
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PatternScoreBreakdown:
|
||||
"""Finite 0-100 scores for the four versioned pattern dimensions."""
|
||||
|
||||
trend_structure: float
|
||||
kdj_state: float
|
||||
volume_pattern: float
|
||||
price_shape: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject non-finite or out-of-range values before persistence."""
|
||||
|
||||
for name in ("trend_structure", "kdj_state", "volume_pattern", "price_shape"):
|
||||
_validate_score(getattr(self, name), name)
|
||||
|
||||
def as_dict(self) -> dict[str, float]:
|
||||
"""Return the JSONB/HTTP field names without exposing dataclass internals."""
|
||||
|
||||
return {
|
||||
"trend_structure": self.trend_structure,
|
||||
"kdj_state": self.kdj_state,
|
||||
"volume_pattern": self.volume_pattern,
|
||||
"price_shape": self.price_shape,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PatternScore:
|
||||
"""One stock-level scoring outcome independent of selection status."""
|
||||
|
||||
status: PatternScoreStatus = "not_executed"
|
||||
value: float | None = None
|
||||
threshold: float | None = None
|
||||
version: str | None = None
|
||||
case: PatternCaseDefinition | None = None
|
||||
breakdown: PatternScoreBreakdown | None = None
|
||||
reason: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Enforce complete successful results and value-free failures."""
|
||||
|
||||
if self.status in {"matched", "below_threshold"}:
|
||||
if (
|
||||
self.value is None
|
||||
or self.threshold is None
|
||||
or self.version is None
|
||||
or self.case is None
|
||||
or self.breakdown is None
|
||||
):
|
||||
raise ValueError("computed pattern score requires complete match context")
|
||||
_validate_score(self.value, "value")
|
||||
_validate_score(self.threshold, "threshold")
|
||||
if (self.value >= self.threshold) != (self.status == "matched"):
|
||||
raise ValueError("pattern score status must agree with threshold")
|
||||
elif any(
|
||||
value is not None
|
||||
for value in (self.value, self.threshold, self.version, self.case, self.breakdown)
|
||||
):
|
||||
raise ValueError("uncomputed pattern score cannot carry match values")
|
||||
if self.status == "failed" and not (self.reason and self.reason.strip()):
|
||||
raise ValueError("failed pattern score requires a safe reason")
|
||||
if self.status == "not_executed" and self.reason is not None:
|
||||
raise ValueError("not-executed pattern score cannot carry a reason")
|
||||
|
||||
@classmethod
|
||||
def failed(cls, reason: str) -> PatternScore:
|
||||
"""Create a safe failure without retaining raw exception details."""
|
||||
|
||||
normalized = " ".join(reason.split())[:500] or "pattern scoring failed"
|
||||
return cls(status="failed", reason=normalized)
|
||||
|
||||
|
||||
class PatternCaseLibraryError(RuntimeError):
|
||||
"""The immutable ten-case library could not be loaded completely."""
|
||||
|
||||
|
||||
class PatternScoringError(RuntimeError):
|
||||
"""A candidate could not be scored under the versioned algorithm."""
|
||||
|
||||
|
||||
class PatternCaseLibraryLoader(Protocol):
|
||||
"""Load the complete versioned case library once for a selection run."""
|
||||
|
||||
def load(self) -> tuple[PatternCase, ...]: ...
|
||||
|
||||
|
||||
class PatternScorer(Protocol):
|
||||
"""Score one selected stock against an already prepared case library."""
|
||||
|
||||
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore: ...
|
||||
|
||||
|
||||
class PatternFeatureExtractor:
|
||||
"""Reproduce the legacy 25-row feature formulas with finite outputs."""
|
||||
|
||||
def extract(self, history: StockHistory) -> PatternFeatures:
|
||||
"""Extract features from the latest 25 ascending, complete OHLCV rows.
|
||||
|
||||
Raises:
|
||||
PatternScoringError: If the history does not contain exactly the
|
||||
required complete window or dates are not strictly ascending.
|
||||
"""
|
||||
|
||||
bars = history.bars[-PATTERN_LOOKBACK_DAYS:]
|
||||
_validate_window(bars, history.ts_code)
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"open": [bar.open for bar in bars],
|
||||
"high": [bar.high for bar in bars],
|
||||
"low": [bar.low for bar in bars],
|
||||
"close": [bar.close for bar in bars],
|
||||
"volume": [bar.volume for bar in bars],
|
||||
},
|
||||
dtype=float,
|
||||
)
|
||||
white = frame["close"].ewm(span=10, adjust=False).mean()
|
||||
white = white.ewm(span=10, adjust=False).mean()
|
||||
yellow = (
|
||||
frame["close"].rolling(14, min_periods=14).mean()
|
||||
+ frame["close"].rolling(28, min_periods=28).mean()
|
||||
+ frame["close"].rolling(57, min_periods=57).mean()
|
||||
+ frame["close"].rolling(114, min_periods=114).mean()
|
||||
) / 4.0
|
||||
frame["short_term_trend"] = white
|
||||
frame["bull_bear_line"] = yellow
|
||||
frame = _legacy_kdj(frame)
|
||||
return PatternFeatures(
|
||||
trend_structure=_trend_features(frame),
|
||||
kdj_state=_kdj_features(frame),
|
||||
volume_pattern=_volume_features(frame),
|
||||
price_shape=_price_features(frame),
|
||||
)
|
||||
|
||||
|
||||
class ZhixingB1PatternScorer:
|
||||
"""Select the stable best case using working scalar FastDTW radius one."""
|
||||
|
||||
def __init__(self, extractor: PatternFeatureExtractor | None = None) -> None:
|
||||
"""Inject an extractor for deterministic unit tests."""
|
||||
|
||||
self.extractor = extractor or PatternFeatureExtractor()
|
||||
|
||||
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore:
|
||||
"""Score one selected history once against all ten ordered cases.
|
||||
|
||||
Raises:
|
||||
PatternScoringError: If the library is incomplete or FastDTW
|
||||
cannot produce a finite distance. No alternative algorithm is
|
||||
used when FastDTW fails.
|
||||
"""
|
||||
|
||||
if tuple(case.definition for case in cases) != ZHIXING_B1_PATTERN_CASES:
|
||||
raise PatternScoringError("pattern case library is incomplete or out of order")
|
||||
candidate = self.extractor.extract(history)
|
||||
best: tuple[float, PatternCase, PatternScoreBreakdown] | None = None
|
||||
for case in cases:
|
||||
breakdown = _match(candidate, case.features)
|
||||
value = round(
|
||||
sum(
|
||||
breakdown.as_dict()[name] / 100.0 * weight
|
||||
for name, weight in PATTERN_WEIGHTS.items()
|
||||
)
|
||||
* 100.0,
|
||||
2,
|
||||
)
|
||||
_validate_score(value, "value")
|
||||
if best is None or value > best[0]:
|
||||
best = (value, case, breakdown)
|
||||
if best is None:
|
||||
raise PatternScoringError("pattern case library is empty")
|
||||
value, case, breakdown = best
|
||||
return PatternScore(
|
||||
status="matched" if value >= PATTERN_SCORE_THRESHOLD else "below_threshold",
|
||||
value=value,
|
||||
threshold=PATTERN_SCORE_THRESHOLD,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=case.definition,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
||||
|
||||
def build_pattern_case(
|
||||
definition: PatternCaseDefinition,
|
||||
history: StockHistory,
|
||||
extractor: PatternFeatureExtractor | None = None,
|
||||
) -> PatternCase:
|
||||
"""Validate and precompute one versioned case for run-wide reuse."""
|
||||
|
||||
if history.ts_code != definition.ts_code:
|
||||
raise PatternCaseLibraryError(f"case {definition.id} code does not match definition")
|
||||
if len(history.bars) != definition.lookback_days:
|
||||
raise PatternCaseLibraryError(
|
||||
f"case {definition.id} requires {definition.lookback_days} complete rows"
|
||||
)
|
||||
try:
|
||||
features = (extractor or PatternFeatureExtractor()).extract(history)
|
||||
except PatternScoringError as exc:
|
||||
raise PatternCaseLibraryError(f"case {definition.id} history is invalid") from exc
|
||||
return PatternCase(definition=definition, history=history, features=features)
|
||||
|
||||
|
||||
def _match(candidate: PatternFeatures, case: PatternFeatures) -> PatternScoreBreakdown:
|
||||
return PatternScoreBreakdown(
|
||||
trend_structure=round(_trend_similarity(candidate, case) * 100.0, 2),
|
||||
kdj_state=round(_kdj_similarity(candidate, case) * 100.0, 2),
|
||||
volume_pattern=round(_volume_similarity(candidate, case) * 100.0, 2),
|
||||
price_shape=round(_price_similarity(candidate, case) * 100.0, 2),
|
||||
)
|
||||
|
||||
|
||||
def _trend_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
|
||||
c, s = candidate.trend_structure, case.trend_structure
|
||||
values = [
|
||||
_difference_similarity(c.get("short_vs_bullbear"), s.get("short_vs_bullbear"), 0.10),
|
||||
_slope_similarity(c.get("short_slope"), s.get("short_slope")),
|
||||
1.0 if c.get("is_in_bowl") == s.get("is_in_bowl") else 0.2,
|
||||
_difference_similarity(c.get("price_vs_short_pct"), s.get("price_vs_short_pct"), 10.0),
|
||||
_difference_similarity(c.get("trend_spread_pct"), s.get("trend_spread_pct"), 10.0),
|
||||
_difference_similarity(c.get("price_bias_pct"), s.get("price_bias_pct"), 10.0),
|
||||
]
|
||||
return float(np.mean(values))
|
||||
|
||||
|
||||
def _kdj_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
|
||||
c, s = candidate.kdj_state, case.kdj_state
|
||||
values = [
|
||||
1.0 if c.get("j_position") == s.get("j_position") else 0.4,
|
||||
_difference_similarity(c.get("j_value"), s.get("j_value"), 30.0),
|
||||
1.0 if c.get("k_cross_d") == s.get("k_cross_d") else 0.6,
|
||||
1.0 if c.get("j_rebound") == s.get("j_rebound") else 0.7,
|
||||
]
|
||||
return float(np.mean(values))
|
||||
|
||||
|
||||
def _volume_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
|
||||
c, s = candidate.volume_pattern, case.volume_pattern
|
||||
values = [
|
||||
_difference_similarity(c.get("avg_volume_ratio"), s.get("avg_volume_ratio"), 1.5),
|
||||
1.0 if c.get("shrink_then_expand") == s.get("shrink_then_expand") else 0.5,
|
||||
1.0 if c.get("volume_trend") == s.get("volume_trend") else 0.6,
|
||||
_difference_similarity(c.get("max_volume_ratio"), s.get("max_volume_ratio"), 3.0),
|
||||
]
|
||||
return float(np.mean(values))
|
||||
|
||||
|
||||
def _price_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
|
||||
c, s = candidate.price_shape, case.price_shape
|
||||
candidate_curve = cast(tuple[float, ...], c.get("normalized_curve"))
|
||||
case_curve = cast(tuple[float, ...], s.get("normalized_curve"))
|
||||
distance, _path = _fastdtw()( # radius and scalar metric are versioned behavior
|
||||
candidate_curve,
|
||||
case_curve,
|
||||
radius=PATTERN_FASTDTW_RADIUS,
|
||||
dist=_scalar_euclidean,
|
||||
)
|
||||
if not isfinite(float(distance)):
|
||||
raise PatternScoringError("FastDTW returned a non-finite distance")
|
||||
values = [
|
||||
max(0.0, 1.0 - float(distance) / max(len(candidate_curve), len(case_curve))),
|
||||
_difference_similarity(c.get("max_drawdown"), s.get("max_drawdown"), 15.0),
|
||||
_difference_similarity(c.get("breakout_strength"), s.get("breakout_strength"), 5.0),
|
||||
1.0 if c.get("overall_trend") == s.get("overall_trend") else 0.5,
|
||||
_difference_similarity(c.get("consolidation_days"), s.get("consolidation_days"), 10.0),
|
||||
]
|
||||
return float(np.mean(values))
|
||||
|
||||
|
||||
def _fastdtw() -> Callable[..., tuple[float, list[tuple[int, int]]]]:
|
||||
"""Give the untyped extension one narrow, checked call signature."""
|
||||
|
||||
return cast(Callable[..., tuple[float, list[tuple[int, int]]]], fastdtw)
|
||||
|
||||
|
||||
def _scalar_euclidean(left: float, right: float) -> float:
|
||||
"""Return Euclidean distance for scalar one-dimensional curve points."""
|
||||
|
||||
return abs(float(left) - float(right))
|
||||
|
||||
|
||||
def _difference_similarity(left: object, right: object, tolerance: float) -> float:
|
||||
left_number = _finite_float(left)
|
||||
right_number = _finite_float(right)
|
||||
if left_number is None or right_number is None:
|
||||
return 0.0
|
||||
return max(0.0, 1.0 - abs(left_number - right_number) / tolerance)
|
||||
|
||||
|
||||
def _slope_similarity(left: object, right: object) -> float:
|
||||
left_number = _finite_float(left)
|
||||
right_number = _finite_float(right)
|
||||
if left_number is None or right_number is None:
|
||||
return 0.0
|
||||
difference = abs(left_number - right_number)
|
||||
if (left_number > 0) == (right_number > 0):
|
||||
return max(0.7, 1.0 - difference / 10.0)
|
||||
return max(0.0, 0.3 - difference / 20.0)
|
||||
|
||||
|
||||
def _trend_features(frame: pd.DataFrame) -> dict[str, float | bool | None]:
|
||||
latest = frame.iloc[-1]
|
||||
short = float(latest["short_term_trend"])
|
||||
bullbear = float(latest["bull_bear_line"])
|
||||
short_previous = float(frame["short_term_trend"].iloc[-5])
|
||||
bullbear_previous = float(frame["bull_bear_line"].iloc[-5])
|
||||
close = float(latest["close"])
|
||||
average = (short + bullbear) / 2.0
|
||||
return {
|
||||
"short_vs_bullbear": _finite_round(short / bullbear if bullbear else 1.0, 4),
|
||||
"short_slope": _finite_round(
|
||||
(short / short_previous - 1.0) * 100.0 if short_previous else 0.0,
|
||||
4,
|
||||
),
|
||||
"bullbear_slope": _finite_round(
|
||||
(bullbear / bullbear_previous - 1.0) * 100.0 if bullbear_previous else 0.0,
|
||||
4,
|
||||
),
|
||||
"price_vs_short_pct": _finite_round((close - short) / short * 100.0 if short else 0.0, 4),
|
||||
"price_vs_bullbear_pct": _finite_round(
|
||||
(close - bullbear) / bullbear * 100.0 if bullbear else 0.0,
|
||||
4,
|
||||
),
|
||||
"is_in_bowl": bool(short > close > bullbear),
|
||||
"trend_spread_pct": _finite_round(
|
||||
(short - bullbear) / bullbear * 100.0 if bullbear else 0.0,
|
||||
4,
|
||||
),
|
||||
"price_bias_pct": _finite_round((close - average) / average * 100.0 if average else 0.0, 4),
|
||||
}
|
||||
|
||||
|
||||
def _kdj_features(frame: pd.DataFrame) -> dict[str, float | bool | str | None]:
|
||||
latest = frame.iloc[-1]
|
||||
j_values = frame["J"].to_numpy(dtype=float)
|
||||
recent = j_values[-5:]
|
||||
j_trend = float(np.polyfit(np.arange(5), recent, 1)[0]) if np.isfinite(recent).all() else 0.0
|
||||
previous = frame.iloc[-2]
|
||||
j_value = float(latest["J"]) if pd.notna(latest["J"]) else 50.0
|
||||
return {
|
||||
"j_value": _finite_round(j_value, 2),
|
||||
"j_trend": _finite_round(j_trend, 4),
|
||||
"j_min_lookback": _finite_round(float(frame["J"].min()), 2),
|
||||
"k_cross_d": bool(previous["K"] < previous["D"] and latest["K"] > latest["D"]),
|
||||
"j_position": "低位" if j_value <= 20 else ("高位" if j_value >= 80 else "中位"),
|
||||
"j_rebound": bool(j_values[-1] > j_values[-3]),
|
||||
}
|
||||
|
||||
|
||||
def _volume_features(frame: pd.DataFrame) -> dict[str, float | bool | str | int | None]:
|
||||
volumes = frame["volume"].to_numpy(dtype=float)
|
||||
recent_average = float(np.mean(volumes[-10:]))
|
||||
before_average = float(np.mean(volumes[-20:-10]))
|
||||
average_ratio = recent_average / before_average if before_average > 0 else 1.0
|
||||
ratios = [
|
||||
volumes[index] / volumes[index - 1] for index in range(1, 20) if volumes[index - 1] > 0
|
||||
]
|
||||
midpoint = len(volumes) // 2
|
||||
early, late = float(np.mean(volumes[:midpoint])), float(np.mean(volumes[midpoint:]))
|
||||
shrink_expand = bool(late > early * 1.3 and early < float(np.mean(volumes)) * 0.9)
|
||||
key_count = sum(
|
||||
1
|
||||
for index in range(1, len(frame))
|
||||
if frame["volume"].iloc[index] > frame["volume"].iloc[index - 1] * 2
|
||||
and frame["close"].iloc[index] > frame["open"].iloc[index]
|
||||
)
|
||||
slope = float(np.polyfit(np.arange(len(volumes)), volumes, 1)[0])
|
||||
slope_pct = slope / float(np.mean(volumes)) * 100.0 if float(np.mean(volumes)) > 0 else 0.0
|
||||
trend = (
|
||||
"持续放量"
|
||||
if slope_pct > 5
|
||||
else "持续缩量"
|
||||
if slope_pct < -5
|
||||
else "缩量后放量"
|
||||
if shrink_expand
|
||||
else "量能平稳"
|
||||
)
|
||||
return {
|
||||
"avg_volume_ratio": _finite_round(average_ratio, 2),
|
||||
"max_volume_ratio": _finite_round(max(ratios, default=1.0), 2),
|
||||
"volume_trend": trend,
|
||||
"key_candles_count": key_count,
|
||||
"shrink_then_expand": shrink_expand,
|
||||
}
|
||||
|
||||
|
||||
def _price_features(frame: pd.DataFrame) -> dict[str, float | str | int | tuple[float, ...] | None]:
|
||||
closes = frame["close"].to_numpy(dtype=float)
|
||||
minimum, maximum = float(closes.min()), float(closes.max())
|
||||
normalized = (
|
||||
tuple(float(value) for value in (closes - minimum) / (maximum - minimum))
|
||||
if maximum > minimum
|
||||
else (0.0,) * len(closes)
|
||||
)
|
||||
peak = np.maximum.accumulate(closes)
|
||||
max_drawdown = float(((peak - closes) / peak).max()) * 100.0
|
||||
breakout = (closes[-1] / closes[-2] - 1.0) * 100.0
|
||||
returns = np.diff(closes) / closes[:-1]
|
||||
volatility = float(np.std(returns)) * 100.0
|
||||
consolidation, current = 0, 0
|
||||
for index in range(len(frame) - 5):
|
||||
window = closes[index : index + 5]
|
||||
if window.max() > 0 and (window.max() - window.min()) / window.max() < 0.05:
|
||||
current += 1
|
||||
consolidation = max(consolidation, current)
|
||||
else:
|
||||
current = 0
|
||||
trend = (
|
||||
"上升"
|
||||
if closes[-1] > closes[0] * 1.05
|
||||
else "下降"
|
||||
if closes[-1] < closes[0] * 0.95
|
||||
else "震荡"
|
||||
)
|
||||
return {
|
||||
"consolidation_days": consolidation,
|
||||
"max_drawdown": _finite_round(max_drawdown, 2),
|
||||
"breakout_strength": _finite_round(breakout, 2),
|
||||
"normalized_curve": normalized,
|
||||
"volatility": _finite_round(volatility, 4),
|
||||
"overall_trend": trend,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_kdj(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
low = frame["low"].rolling(window=9, min_periods=1).min()
|
||||
high = frame["high"].rolling(window=9, min_periods=1).max()
|
||||
rsv = ((frame["close"] - low) / (high - low + 1e-9) * 100.0).to_numpy(dtype=float)
|
||||
k = np.empty(len(rsv), dtype=float)
|
||||
d = np.empty(len(rsv), dtype=float)
|
||||
k[0] = d[0] = 50.0
|
||||
for index in range(1, len(rsv)):
|
||||
k[index] = 2.0 / 3.0 * k[index - 1] + 1.0 / 3.0 * rsv[index]
|
||||
d[index] = 2.0 / 3.0 * d[index - 1] + 1.0 / 3.0 * k[index]
|
||||
return frame.assign(K=k, D=d, J=3.0 * k - 2.0 * d)
|
||||
|
||||
|
||||
def _validate_window(bars: Sequence[SelectionBar], ts_code: str) -> None:
|
||||
if len(bars) != PATTERN_LOOKBACK_DAYS:
|
||||
raise PatternScoringError(f"{ts_code} requires {PATTERN_LOOKBACK_DAYS} complete rows")
|
||||
if any(
|
||||
left.trade_date >= right.trade_date for left, right in zip(bars, bars[1:], strict=False)
|
||||
):
|
||||
raise PatternScoringError(f"{ts_code} pattern rows must be strictly ascending")
|
||||
if any(
|
||||
value is None
|
||||
for bar in bars
|
||||
for value in (bar.open, bar.high, bar.low, bar.close, bar.volume)
|
||||
):
|
||||
raise PatternScoringError(f"{ts_code} pattern rows require complete OHLCV")
|
||||
|
||||
|
||||
def _finite_float(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, Real):
|
||||
return None
|
||||
number = float(value)
|
||||
return number if isfinite(number) else None
|
||||
|
||||
|
||||
def _finite_round(value: float, digits: int) -> float | None:
|
||||
return round(float(value), digits) if isfinite(float(value)) else None
|
||||
|
||||
|
||||
def _validate_score(value: float, name: str) -> None:
|
||||
if not isfinite(value) or value < 0 or value > 100:
|
||||
raise ValueError(f"{name} must be finite and between 0 and 100")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PATTERN_FASTDTW_RADIUS",
|
||||
"PATTERN_LOOKBACK_DAYS",
|
||||
"PATTERN_SCORE_THRESHOLD",
|
||||
"PATTERN_SCORING_VERSION",
|
||||
"PatternCase",
|
||||
"PatternCaseDefinition",
|
||||
"PatternCaseLibraryError",
|
||||
"PatternCaseLibraryLoader",
|
||||
"PatternFeatureExtractor",
|
||||
"PatternFeatures",
|
||||
"PatternScore",
|
||||
"PatternScoreBreakdown",
|
||||
"PatternScorer",
|
||||
"PatternScoringError",
|
||||
"ZHIXING_B1_PATTERN_CASES",
|
||||
"ZhixingB1PatternScorer",
|
||||
"build_pattern_case",
|
||||
]
|
||||
@@ -9,10 +9,12 @@ from decimal import Decimal
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
|
||||
from .pattern_scoring import PatternScore
|
||||
|
||||
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
|
||||
SelectionRunItemStatus = SelectionEvaluationStatus
|
||||
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"]
|
||||
SelectionResultSort = Literal["code", "score_desc", "score_asc"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -23,6 +25,7 @@ class SelectionResultQuery:
|
||||
page_size: int = 10
|
||||
search: str | None = None
|
||||
category: SelectionSignalCategoryFilter | None = None
|
||||
sort: SelectionResultSort = "code"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -54,6 +57,7 @@ class SelectionRunItem:
|
||||
status: SelectionRunItemStatus
|
||||
signal_count: int = 0
|
||||
reason: str | None = None
|
||||
pattern_score: PatternScore = field(default_factory=PatternScore)
|
||||
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
|
||||
+141
@@ -12,6 +12,12 @@ import psycopg
|
||||
|
||||
from ....bootstrap.config import Settings
|
||||
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
|
||||
from ..domain.pattern_scoring import (
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternCase,
|
||||
PatternCaseLibraryError,
|
||||
build_pattern_case,
|
||||
)
|
||||
from ..domain.ports import MarketDataReaderError
|
||||
from ..domain.runs import SelectionExecutionSource, SelectionStock
|
||||
from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool
|
||||
@@ -103,6 +109,38 @@ WHERE stock.is_active = true
|
||||
ORDER BY stock.ts_code
|
||||
"""
|
||||
|
||||
_PATTERN_CASES_QUERY = """
|
||||
WITH case_definition AS (
|
||||
SELECT *
|
||||
FROM unnest(%s::text[], %s::text[], %s::date[], %s::integer[])
|
||||
AS definition(case_id, ts_code, breakout_date, lookback_days)
|
||||
), ranked AS (
|
||||
SELECT
|
||||
definition.case_id,
|
||||
bar.ts_code,
|
||||
bar.trade_date,
|
||||
bar.open,
|
||||
bar.high,
|
||||
bar.low,
|
||||
bar.close,
|
||||
bar.vol,
|
||||
row_number() OVER (
|
||||
PARTITION BY definition.case_id
|
||||
ORDER BY bar.trade_date DESC
|
||||
) AS recency_rank,
|
||||
definition.lookback_days
|
||||
FROM case_definition AS definition
|
||||
JOIN market_daily_bar AS bar
|
||||
ON bar.ts_code = definition.ts_code
|
||||
AND bar.source_adj = 'qfq'
|
||||
AND bar.trade_date < definition.breakout_date
|
||||
)
|
||||
SELECT case_id, ts_code, trade_date, open, high, low, close, vol
|
||||
FROM ranked
|
||||
WHERE recency_rank <= lookback_days
|
||||
ORDER BY case_id ASC, trade_date ASC
|
||||
"""
|
||||
|
||||
|
||||
def _as_date(value: object) -> date:
|
||||
"""Convert a PostgreSQL date-like scalar to a date."""
|
||||
@@ -386,3 +424,106 @@ class PostgresMarketDataReader:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - normalize pool/driver failures
|
||||
raise SelectionReaderError("selection database operation failed") from exc
|
||||
|
||||
|
||||
class PostgresPatternCaseLibraryLoader:
|
||||
"""Build the complete versioned FastDTW case library from PostgreSQL qfq bars."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | str,
|
||||
*,
|
||||
pool: SelectionPostgresPool | SelectionConnectionPool | None = None,
|
||||
) -> None:
|
||||
"""Create a loader sharing the process selection connection pool."""
|
||||
|
||||
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
|
||||
if isinstance(pool, SelectionPostgresPool):
|
||||
self.pool: SelectionPostgresPool | None = pool
|
||||
elif pool is not None:
|
||||
self.pool = SelectionPostgresPool(self.database_url, max_connections=1, pool=pool)
|
||||
else:
|
||||
self.pool = None
|
||||
|
||||
def load(self) -> tuple[PatternCase, ...]:
|
||||
"""Load all ten exclusive pre-breakout windows exactly once.
|
||||
|
||||
Returns:
|
||||
Ordered, feature-precomputed cases matching the versioned definitions.
|
||||
|
||||
Raises:
|
||||
PatternCaseLibraryError: If the query fails or any case lacks a
|
||||
complete finite 25-row qfq window.
|
||||
"""
|
||||
|
||||
definitions = ZHIXING_B1_PATTERN_CASES
|
||||
parameters = (
|
||||
[definition.id for definition in definitions],
|
||||
[definition.ts_code for definition in definitions],
|
||||
[definition.breakout_date for definition in definitions],
|
||||
[definition.lookback_days for definition in definitions],
|
||||
)
|
||||
try:
|
||||
with self._connection() as connection:
|
||||
rows = connection.execute(_PATTERN_CASES_QUERY, parameters).fetchall()
|
||||
rows_by_case: dict[str, list[tuple[object, ...]]] = {
|
||||
definition.id: [] for definition in definitions
|
||||
}
|
||||
for raw_row in rows:
|
||||
row = cast(tuple[object, ...], raw_row)
|
||||
case_id = str(row[0])
|
||||
if case_id not in rows_by_case:
|
||||
raise PatternCaseLibraryError(f"unexpected pattern case row: {case_id}")
|
||||
rows_by_case[case_id].append(row)
|
||||
|
||||
cases: list[PatternCase] = []
|
||||
for definition in definitions:
|
||||
case_rows = rows_by_case[definition.id]
|
||||
if len(case_rows) != definition.lookback_days:
|
||||
raise PatternCaseLibraryError(
|
||||
f"case {definition.id} requires {definition.lookback_days} qfq rows"
|
||||
)
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=_as_date(row[2]),
|
||||
open=_as_float(row[3]),
|
||||
high=_as_float(row[4]),
|
||||
low=_as_float(row[5]),
|
||||
close=_as_float(row[6]),
|
||||
volume=_as_float(row[7]),
|
||||
)
|
||||
for row in case_rows
|
||||
)
|
||||
if any(bar.trade_date >= definition.breakout_date for bar in bars):
|
||||
raise PatternCaseLibraryError(
|
||||
f"case {definition.id} contains a non-exclusive breakout row"
|
||||
)
|
||||
history = StockHistory(
|
||||
ts_code=definition.ts_code,
|
||||
name=definition.name,
|
||||
bars=bars,
|
||||
)
|
||||
cases.append(build_pattern_case(definition, history))
|
||||
return tuple(cases)
|
||||
except PatternCaseLibraryError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - redact database details at the port boundary
|
||||
raise PatternCaseLibraryError(
|
||||
"failed to load the complete pattern case library"
|
||||
) from exc
|
||||
|
||||
@contextmanager
|
||||
def _connection(self) -> Generator[Any, None, None]:
|
||||
"""Borrow a shared connection without exposing driver failures."""
|
||||
|
||||
try:
|
||||
if self.pool is None:
|
||||
with psycopg.connect(self.database_url) as connection:
|
||||
yield connection
|
||||
else:
|
||||
with self.pool.connection() as connection:
|
||||
yield connection
|
||||
except PatternCaseLibraryError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - normalize driver/pool errors
|
||||
raise PatternCaseLibraryError("pattern case database operation failed") from exc
|
||||
|
||||
+113
-12
@@ -15,6 +15,11 @@ import psycopg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..domain.models import SelectionSignal, ZhixingB1Category
|
||||
from ..domain.pattern_scoring import (
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from ..domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -44,17 +49,37 @@ _SIGNAL_ORDER_SQL = (
|
||||
)
|
||||
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END"
|
||||
)
|
||||
_PATTERN_CASES_BY_ID = {definition.id: definition for definition in ZHIXING_B1_PATTERN_CASES}
|
||||
_STOCK_ORDER_SQL = {
|
||||
"code": "item.ts_code ASC",
|
||||
"score_desc": "item.score_value DESC NULLS LAST, item.ts_code ASC",
|
||||
"score_asc": "item.score_value ASC NULLS LAST, item.ts_code ASC",
|
||||
}
|
||||
|
||||
|
||||
_ITEM_UPSERT = """
|
||||
INSERT INTO selection_run_item
|
||||
(run_id, ts_code, name, status, signal_count, reason)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
(
|
||||
run_id, ts_code, name, status, signal_count, reason,
|
||||
score_status, score_value, score_threshold, score_version,
|
||||
match_case_id, match_case_name, match_case_breakout_date,
|
||||
match_breakdown, score_reason
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (run_id, ts_code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
signal_count = EXCLUDED.signal_count,
|
||||
reason = EXCLUDED.reason
|
||||
reason = EXCLUDED.reason,
|
||||
score_status = EXCLUDED.score_status,
|
||||
score_value = EXCLUDED.score_value,
|
||||
score_threshold = EXCLUDED.score_threshold,
|
||||
score_version = EXCLUDED.score_version,
|
||||
match_case_id = EXCLUDED.match_case_id,
|
||||
match_case_name = EXCLUDED.match_case_name,
|
||||
match_case_breakout_date = EXCLUDED.match_case_breakout_date,
|
||||
match_breakdown = EXCLUDED.match_breakdown,
|
||||
score_reason = EXCLUDED.score_reason
|
||||
"""
|
||||
_SIGNAL_UPSERT = """
|
||||
INSERT INTO selection_signal
|
||||
@@ -200,6 +225,19 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
item.status,
|
||||
item.signal_count,
|
||||
item.reason,
|
||||
item.pattern_score.status,
|
||||
item.pattern_score.value,
|
||||
item.pattern_score.threshold,
|
||||
item.pattern_score.version,
|
||||
item.pattern_score.case.id if item.pattern_score.case else None,
|
||||
item.pattern_score.case.name if item.pattern_score.case else None,
|
||||
item.pattern_score.case.breakout_date if item.pattern_score.case else None,
|
||||
(
|
||||
Jsonb(item.pattern_score.breakdown.as_dict())
|
||||
if item.pattern_score.breakdown
|
||||
else None
|
||||
),
|
||||
item.pattern_score.reason,
|
||||
)
|
||||
for item in items
|
||||
)
|
||||
@@ -354,7 +392,11 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
return None
|
||||
item_rows = connection.execute(
|
||||
"""
|
||||
SELECT ts_code, name, status, signal_count, reason
|
||||
SELECT
|
||||
ts_code, name, status, signal_count, reason,
|
||||
score_status, score_value, score_threshold, score_version,
|
||||
match_case_id, match_case_name, match_case_breakout_date,
|
||||
match_breakdown, score_reason
|
||||
FROM selection_run_item
|
||||
WHERE run_id = %s
|
||||
ORDER BY ts_code
|
||||
@@ -363,7 +405,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
).fetchall()
|
||||
stock_filter, stock_parameters = _stock_filter(query, run_id)
|
||||
stock_total_row = connection.execute(
|
||||
f"SELECT COUNT(DISTINCT ts_code) FROM selection_signal WHERE {stock_filter}",
|
||||
f"SELECT COUNT(*) FROM selection_run_item AS item WHERE {stock_filter}",
|
||||
tuple(stock_parameters),
|
||||
).fetchone()
|
||||
stock_total = int(stock_total_row[0] or 0) if stock_total_row else 0
|
||||
@@ -372,10 +414,10 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
list[tuple[object, ...]],
|
||||
connection.execute(
|
||||
f"""
|
||||
SELECT DISTINCT ts_code
|
||||
FROM selection_signal
|
||||
SELECT item.ts_code
|
||||
FROM selection_run_item AS item
|
||||
WHERE {stock_filter}
|
||||
ORDER BY ts_code
|
||||
ORDER BY {_STOCK_ORDER_SQL[query.sort]}
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple((*stock_parameters, query.page_size, offset)),
|
||||
@@ -403,7 +445,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
sorted(
|
||||
(_signal_from_row(value) for value in signal_rows),
|
||||
key=lambda signal: (
|
||||
signal.ts_code,
|
||||
stock_codes.index(signal.ts_code),
|
||||
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
|
||||
),
|
||||
)
|
||||
@@ -427,6 +469,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
),
|
||||
signal_count=int(value[3] or 0),
|
||||
reason=str(value[4]) if value[4] is not None else None,
|
||||
pattern_score=_pattern_score_from_row(value[5:14]),
|
||||
signals=tuple(signals_by_stock.get(str(value[0]), ())),
|
||||
)
|
||||
for value in item_rows
|
||||
@@ -509,18 +552,76 @@ def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[o
|
||||
can present all independently persisted categories together.
|
||||
"""
|
||||
|
||||
clauses = ["run_id = %s"]
|
||||
clauses = ["item.run_id = %s", "item.status = 'selected'", "item.signal_count > 0"]
|
||||
parameters: list[object] = [run_id]
|
||||
if query.search:
|
||||
pattern = f"%{_escape_like(query.search)}%"
|
||||
clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')")
|
||||
clauses.append("(item.name ILIKE %s ESCAPE '\\' OR item.ts_code ILIKE %s ESCAPE '\\')")
|
||||
parameters.extend((pattern, pattern))
|
||||
if query.category:
|
||||
clauses.append("category LIKE %s")
|
||||
clauses.append(
|
||||
"EXISTS ("
|
||||
"SELECT 1 FROM selection_signal AS signal "
|
||||
"WHERE signal.run_id = item.run_id "
|
||||
"AND signal.ts_code = item.ts_code "
|
||||
"AND signal.category LIKE %s"
|
||||
")"
|
||||
)
|
||||
parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
|
||||
return " AND ".join(clauses), parameters
|
||||
|
||||
|
||||
def _pattern_score_from_row(row: Sequence[object]) -> PatternScore:
|
||||
"""Reconstruct a validated stock-level score from nullable item columns."""
|
||||
|
||||
if len(row) < 9:
|
||||
return PatternScore()
|
||||
status = str(row[0] or "not_executed")
|
||||
if status == "not_executed":
|
||||
return PatternScore()
|
||||
if status == "failed":
|
||||
return PatternScore.failed(str(row[8] or "pattern scoring failed"))
|
||||
if status not in {"matched", "below_threshold"}:
|
||||
return PatternScore.failed("persisted pattern score status is invalid")
|
||||
definition = _PATTERN_CASES_BY_ID.get(str(row[4]))
|
||||
breakdown = _pattern_breakdown(row[7])
|
||||
if definition is None or breakdown is None:
|
||||
return PatternScore.failed("persisted pattern score is incomplete")
|
||||
try:
|
||||
return PatternScore(
|
||||
status=cast(Literal["matched", "below_threshold"], status),
|
||||
value=float(str(row[1])),
|
||||
threshold=float(str(row[2])),
|
||||
version=str(row[3]),
|
||||
case=definition,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return PatternScore.failed("persisted pattern score is invalid")
|
||||
|
||||
|
||||
def _pattern_breakdown(value: object) -> PatternScoreBreakdown | None:
|
||||
"""Parse the four finite JSONB score dimensions."""
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
values = cast(Mapping[object, object], value)
|
||||
try:
|
||||
return PatternScoreBreakdown(
|
||||
trend_structure=float(str(values["trend_structure"])),
|
||||
kdj_state=float(str(values["kdj_state"])),
|
||||
volume_pattern=float(str(values["volume_pattern"])),
|
||||
price_shape=float(str(values["price_shape"])),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape user wildcards before placing text inside a SQL LIKE pattern."""
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ from zhixing_server.modules.selection.application.run import (
|
||||
RunZhixingB1,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PatternScore,
|
||||
ZhixingB1PatternScorer,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
@@ -23,6 +27,7 @@ from zhixing_server.modules.selection.domain.runs import (
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||
PostgresMarketDataReader,
|
||||
PostgresPatternCaseLibraryLoader,
|
||||
SelectionMarketDataNotReady,
|
||||
SelectionReaderError,
|
||||
)
|
||||
@@ -82,6 +87,35 @@ class SelectionFailureResponse(BaseModel):
|
||||
reason: str | None
|
||||
|
||||
|
||||
class SelectionPatternCaseResponse(BaseModel):
|
||||
"""The best matching versioned case for one computed score."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
breakout_date: date
|
||||
|
||||
|
||||
class SelectionPatternBreakdownResponse(BaseModel):
|
||||
"""The four finite 0-100 similarity dimensions."""
|
||||
|
||||
trend_structure: float = Field(ge=0, le=100)
|
||||
kdj_state: float = Field(ge=0, le=100)
|
||||
volume_pattern: float = Field(ge=0, le=100)
|
||||
price_shape: float = Field(ge=0, le=100)
|
||||
|
||||
|
||||
class SelectionPatternScoreResponse(BaseModel):
|
||||
"""A stock-level enrichment independent of selection evaluation status."""
|
||||
|
||||
status: Literal["matched", "below_threshold", "failed"]
|
||||
value: float | None = Field(default=None, ge=0, le=100)
|
||||
threshold: float | None = Field(default=None, ge=0, le=100)
|
||||
version: str | None = None
|
||||
case: SelectionPatternCaseResponse | None = None
|
||||
breakdown: SelectionPatternBreakdownResponse | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def _empty_failures() -> list[SelectionFailureResponse]:
|
||||
"""Create a typed default list for Pydantic's strict checker."""
|
||||
|
||||
@@ -102,6 +136,7 @@ class SelectionStockResponse(BaseModel):
|
||||
target_trade_date: date
|
||||
strategy: StrategyValue
|
||||
close: float
|
||||
score: SelectionPatternScoreResponse | None = None
|
||||
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
|
||||
|
||||
|
||||
@@ -144,10 +179,14 @@ def get_selection_service(
|
||||
|
||||
pool = get_selection_postgres_pool(settings)
|
||||
reader = PostgresMarketDataReader(settings, pool=pool)
|
||||
pattern_case_loader = PostgresPatternCaseLibraryLoader(settings, pool=pool)
|
||||
store = PostgresSelectionRunRepository(settings.database_url, pool=pool)
|
||||
return RunZhixingB1(
|
||||
reader,
|
||||
store,
|
||||
pattern_case_loader=pattern_case_loader,
|
||||
pattern_scorer=ZhixingB1PatternScorer(),
|
||||
pattern_scoring_enabled=settings.selection_pattern_scoring_enabled,
|
||||
max_workers=settings.selection_max_workers,
|
||||
batch_size=settings.selection_batch_size,
|
||||
)
|
||||
@@ -225,11 +264,12 @@ def get_selection_run(
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
|
||||
search: Annotated[str | None, Query(max_length=100)] = None,
|
||||
category: Literal["pullback", "oversold", "original"] | None = None,
|
||||
sort: Literal["code", "score_desc", "score_asc"] = "code",
|
||||
) -> SelectionResultsResponse:
|
||||
"""Return one run for asynchronous polling."""
|
||||
|
||||
try:
|
||||
query = _result_query(page, page_size, search, category)
|
||||
query = _result_query(page, page_size, search, category, sort)
|
||||
run = service.get_run(run_id, query=query)
|
||||
except SelectionRunStoreError as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
@@ -247,11 +287,12 @@ def get_selection_results(
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
|
||||
search: Annotated[str | None, Query(max_length=100)] = None,
|
||||
category: Literal["pullback", "oversold", "original"] | None = None,
|
||||
sort: Literal["code", "score_desc", "score_asc"] = "code",
|
||||
) -> SelectionResultsResponse:
|
||||
"""Return the current persisted result for a strategy and optional date."""
|
||||
|
||||
try:
|
||||
query = _result_query(page, page_size, search, category)
|
||||
query = _result_query(page, page_size, search, category, sort)
|
||||
run = service.get_latest(strategy, target_trade_date, query=query)
|
||||
except SelectionRunStoreError as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
@@ -276,6 +317,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
|
||||
signals_by_stock: dict[str, list[SelectionSignalResponse]] = {}
|
||||
for signal in run.signals:
|
||||
signals_by_stock.setdefault(signal.ts_code, []).append(_signal_response(signal))
|
||||
items_by_stock = {item.ts_code: item for item in run.items}
|
||||
|
||||
return SelectionResultsResponse(
|
||||
strategy=run.strategy,
|
||||
@@ -316,6 +358,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
|
||||
target_trade_date=signals[0].target_trade_date,
|
||||
strategy=signals[0].strategy,
|
||||
close=signals[0].close,
|
||||
score=_pattern_score_response(items_by_stock[signals[0].ts_code].pattern_score),
|
||||
signals=signals,
|
||||
)
|
||||
for signals in signals_by_stock.values()
|
||||
@@ -337,11 +380,43 @@ def _signal_response(signal: SelectionSignal) -> SelectionSignalResponse:
|
||||
)
|
||||
|
||||
|
||||
def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreResponse | None:
|
||||
"""Hide not-executed scores and expose validated computed/failure states."""
|
||||
|
||||
if score.status == "not_executed":
|
||||
return None
|
||||
if score.status == "failed":
|
||||
return SelectionPatternScoreResponse(status="failed", reason=score.reason)
|
||||
if score.status == "below_threshold":
|
||||
return SelectionPatternScoreResponse(
|
||||
status="below_threshold",
|
||||
threshold=score.threshold,
|
||||
version=score.version,
|
||||
reason="未匹配到评分阈值以上案例",
|
||||
)
|
||||
if score.case is None or score.breakdown is None:
|
||||
return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整")
|
||||
return SelectionPatternScoreResponse(
|
||||
status=score.status,
|
||||
value=score.value,
|
||||
threshold=score.threshold,
|
||||
version=score.version,
|
||||
case=SelectionPatternCaseResponse(
|
||||
id=score.case.id,
|
||||
name=score.case.name,
|
||||
breakout_date=score.case.breakout_date,
|
||||
),
|
||||
breakdown=SelectionPatternBreakdownResponse(**score.breakdown.as_dict()),
|
||||
reason=score.reason,
|
||||
)
|
||||
|
||||
|
||||
def _result_query(
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: str | None,
|
||||
category: Literal["pullback", "oversold", "original"] | None,
|
||||
sort: Literal["code", "score_desc", "score_asc"],
|
||||
) -> SelectionResultQuery:
|
||||
"""Normalize HTTP query values before handing them to the selection port."""
|
||||
|
||||
@@ -351,6 +426,7 @@ def _result_query(
|
||||
page_size=page_size,
|
||||
search=normalized_search or None,
|
||||
category=category,
|
||||
sort=sort,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# FastDTW v1 离线基线
|
||||
|
||||
十个 CSV 仅保留原项目固定案例突破日前最后 25 个升序交易日,测试运行不读取原项目、网络或数据库。`golden.json` 使用修正后可工作的 FastDTW、标量欧氏距离与 `radius=1` 离线生成;它有意不兼容原项目实际执行的 simple-DTW fallback。
|
||||
|
||||
25 行窗口不足以产生 114 日多空线。领域 extractor 将这些旧公式产生的非有限中间值显式转换为 `None`,matcher 按旧比较的最终效果记为零相似度,保证 dataclass、JSONB 和 HTTP 不包含 `NaN`/`Infinity`。案例库现在要求十例各 25 行完整 OHLCV,不再静默接受部分案例。
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-04-01,27.93,29.03,27.8,28.94,27218.84,5612754000
|
||||
2025-04-02,28.9,29.18,28.69,28.94,12933.01,5612754000
|
||||
2025-04-03,28.71,29.07,28.54,28.73,11232.75,5612754000
|
||||
2025-04-07,27.93,27.93,23.19,24.02,37675.08,5612754000
|
||||
2025-04-08,24.03,25.03,24.03,24.86,16676.65,5612754000
|
||||
2025-04-09,24.49,24.87,23.12,24.73,14468.45,5612754000
|
||||
2025-04-10,24.96,25.53,24.89,25.1,11065.87,5612754000
|
||||
2025-04-11,25.02,25.91,24.7,25.67,11201.14,5612754000
|
||||
2025-04-14,25.75,26.88,25.75,26.29,14566.38,5612754000
|
||||
2025-04-15,26.41,27.19,26.09,26.17,10132.02,5612754000
|
||||
2025-04-16,26.04,26.71,25.88,26.38,13525.38,5612754000
|
||||
2025-04-17,26.11,28.69,26.04,28.39,42729.26,5612754000
|
||||
2025-04-18,28.83,29.54,27.91,28.51,48214.96,5612754000
|
||||
2025-04-21,28.83,31.25,27.94,30.52,96121.97,5612754000
|
||||
2025-04-22,30.52,35.02,30.52,32.77,148408.79,5612754000
|
||||
2025-04-23,32.43,33.71,30.92,32.34,56626.78,5612754000
|
||||
2025-04-24,32.44,34.67,32.44,34.18,48618.11,5612754000
|
||||
2025-04-25,34.18,34.55,30.24,30.67,75477.69,5612754000
|
||||
2025-04-28,30.93,32.63,30.24,31.15,62031.09,5612754000
|
||||
2025-04-29,31.8,32.54,30.97,31.39,34211.51,5612754000
|
||||
2025-04-30,32.02,32.02,29.92,30.19,48359.44,5612754000
|
||||
2025-05-06,30.24,30.62,29.2,29.5,36216.67,5612754000
|
||||
2025-05-07,29.67,30.34,29.18,29.54,26316.22,5612754000
|
||||
2025-05-08,29.54,29.94,29.26,29.82,20883.91,5612754000
|
||||
2025-05-09,29.82,30.44,29.32,29.44,16659.71,5612754000
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-07-02,10.88,11.1,10.57,10.67,1287084.86,12045490456
|
||||
2025-07-03,10.62,10.9,10.54,10.82,991702.0,12045490456
|
||||
2025-07-04,10.83,10.88,10.4,10.45,855100.82,12045490456
|
||||
2025-07-07,10.28,11.26,10.28,10.98,1216456.82,12045490456
|
||||
2025-07-08,10.89,11.52,10.81,11.15,1558398.35,12045490456
|
||||
2025-07-09,11.18,11.23,10.79,10.84,1033990.38,12045490456
|
||||
2025-07-10,11.27,11.86,10.93,11.64,2056513.24,12045490456
|
||||
2025-07-11,11.87,12.46,11.53,12.13,2320402.62,12045490456
|
||||
2025-07-14,12.19,12.46,11.57,11.62,1491417.84,12045490456
|
||||
2025-07-15,11.58,12.78,11.58,12.2,2460847.71,12045490456
|
||||
2025-07-16,11.98,12.04,11.27,11.32,1938934.42,12045490456
|
||||
2025-07-17,11.08,11.49,10.97,11.4,1018625.42,12045490456
|
||||
2025-07-18,11.34,12.14,11.32,11.68,1574602.28,12045490456
|
||||
2025-07-21,11.6,11.98,11.57,11.8,1226347.09,12045490456
|
||||
2025-07-22,11.68,12.0,11.47,11.56,985223.02,12045490456
|
||||
2025-07-23,11.46,11.73,11.2,11.5,751845.98,12045490456
|
||||
2025-07-24,11.42,12.4,11.39,12.27,1884541.46,12045490456
|
||||
2025-07-25,12.22,13.06,12.12,12.61,1848357.03,12045490456
|
||||
2025-07-28,12.91,12.97,12.61,12.69,1106575.15,12045490456
|
||||
2025-07-29,12.41,12.64,12.28,12.4,794365.97,12045490456
|
||||
2025-07-30,12.38,12.44,11.83,12.09,880349.27,12045490456
|
||||
2025-07-31,11.97,12.13,11.75,11.81,547576.88,12045490456
|
||||
2025-08-01,11.8,11.8,11.54,11.58,448552.57,12045490456
|
||||
2025-08-04,11.6,11.68,11.51,11.63,404376.06,12045490456
|
||||
2025-08-05,11.8,11.89,11.63,11.68,518346.76,12045490456
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-05-15,17.33,17.42,17.1,17.25,20642.05,11720226798
|
||||
2025-05-16,17.18,17.73,17.17,17.43,35369.48,11720226798
|
||||
2025-05-19,17.48,17.48,17.08,17.25,25489.78,11720226798
|
||||
2025-05-20,17.36,17.74,17.32,17.53,36708.2,11720226798
|
||||
2025-05-21,17.72,18.22,17.47,17.72,41463.24,11720226798
|
||||
2025-05-22,17.62,17.81,17.37,17.58,40314.58,11720226798
|
||||
2025-05-23,17.52,17.97,17.47,17.51,46281.21,11720226798
|
||||
2025-05-26,17.63,17.63,17.05,17.09,38830.29,11720226798
|
||||
2025-05-27,17.17,17.32,17.0,17.16,42731.45,11720226798
|
||||
2025-05-28,17.17,18.4,17.08,18.21,123423.01,11720226798
|
||||
2025-05-29,18.44,20.16,18.36,19.65,194317.79,11720226798
|
||||
2025-05-30,19.74,19.96,19.39,19.76,132173.99,11720226798
|
||||
2025-06-03,19.86,22.94,19.85,22.36,290301.1,11720226798
|
||||
2025-06-04,22.17,22.76,21.58,22.54,199596.74,11720226798
|
||||
2025-06-05,22.54,23.42,21.96,23.31,231289.99,11720226798
|
||||
2025-06-06,23.01,23.11,21.66,22.86,233436.91,11720226798
|
||||
2025-06-09,22.76,24.44,22.76,23.71,261851.09,11720226798
|
||||
2025-06-10,23.69,23.82,22.66,22.81,190046.45,11720226798
|
||||
2025-06-11,22.89,23.06,22.32,22.37,116651.04,11720226798
|
||||
2025-06-12,22.64,24.05,22.18,23.28,190460.15,11720226798
|
||||
2025-06-13,23.16,23.64,22.72,22.88,106830.32,11720226798
|
||||
2025-06-16,22.88,23.32,22.51,22.75,70989.1,11720226798
|
||||
2025-06-17,23.23,23.41,21.97,22.18,139623.83,11720226798
|
||||
2025-06-18,21.85,22.29,21.61,22.22,100081.97,11720226798
|
||||
2025-06-19,22.22,22.49,21.31,21.44,76485.29,11720226798
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-18,4.65,4.86,4.6,4.83,2051281.76,39275697251
|
||||
2025-06-19,4.79,4.98,4.75,4.78,1715941.43,39275697251
|
||||
2025-06-20,4.77,4.81,4.63,4.65,1051952.17,39275697251
|
||||
2025-06-23,4.6,4.75,4.57,4.7,934722.87,39275697251
|
||||
2025-06-24,4.72,4.81,4.7,4.78,945714.0,39275697251
|
||||
2025-06-25,4.8,4.85,4.73,4.81,1124786.7,39275697251
|
||||
2025-06-26,4.86,5.05,4.83,4.94,2459293.2,39275697251
|
||||
2025-06-27,4.94,5.42,4.85,5.27,4029657.48,39275697251
|
||||
2025-06-30,5.25,5.43,5.25,5.34,2441261.2,39275697251
|
||||
2025-07-01,5.31,5.38,5.24,5.3,1702111.13,39275697251
|
||||
2025-07-02,5.26,5.28,5.05,5.08,1565861.38,39275697251
|
||||
2025-07-03,5.08,5.59,5.08,5.59,4250014.47,39275697251
|
||||
2025-07-04,5.6,5.74,5.52,5.6,4529145.33,39275697251
|
||||
2025-07-07,5.5,5.79,5.49,5.58,2463078.1,39275697251
|
||||
2025-07-08,5.55,5.95,5.53,5.78,3665165.9,39275697251
|
||||
2025-07-09,5.75,5.82,5.65,5.69,2274246.96,39275697251
|
||||
2025-07-10,5.67,5.76,5.51,5.58,2005171.32,39275697251
|
||||
2025-07-11,5.57,5.58,5.39,5.5,1839462.11,39275697251
|
||||
2025-07-14,5.51,5.55,5.42,5.44,1238426.57,39275697251
|
||||
2025-07-15,5.45,5.6,5.4,5.47,2322143.38,39275697251
|
||||
2025-07-16,5.29,5.47,5.29,5.36,1945350.4,39275697251
|
||||
2025-07-17,5.33,5.57,5.3,5.48,2190584.97,39275697251
|
||||
2025-07-18,5.47,5.65,5.45,5.5,2020531.6,39275697251
|
||||
2025-07-21,5.52,5.66,5.43,5.48,1384268.25,39275697251
|
||||
2025-07-22,5.45,5.55,5.35,5.37,1735870.73,39275697251
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-30,32.11,33.05,31.61,32.23,1421653.88,51810407315
|
||||
2025-07-01,31.82,32.18,30.63,31.51,1313625.56,51810407315
|
||||
2025-07-02,31.51,31.57,30.79,30.9,615208.98,51810407315
|
||||
2025-07-03,31.16,31.21,30.49,30.96,810657.45,51810407315
|
||||
2025-07-04,30.65,30.93,29.93,30.43,799767.87,51810407315
|
||||
2025-07-07,30.43,30.67,30.09,30.22,482072.12,51810407315
|
||||
2025-07-08,30.11,30.34,29.97,30.12,621125.66,51810407315
|
||||
2025-07-09,30.19,30.86,29.73,29.83,1103713.14,51810407315
|
||||
2025-07-10,29.58,30.08,29.46,29.7,591122.59,51810407315
|
||||
2025-07-11,29.61,30.49,29.52,30.06,833099.95,51810407315
|
||||
2025-07-14,30.07,30.42,29.69,29.86,504302.93,51810407315
|
||||
2025-07-15,29.75,30.23,29.0,29.19,737141.8,51810407315
|
||||
2025-07-16,29.18,29.45,29.01,29.21,367657.7,51810407315
|
||||
2025-07-17,29.2,29.94,28.85,29.79,688212.84,51810407315
|
||||
2025-07-18,30.09,31.56,29.9,31.0,1211206.13,51810407315
|
||||
2025-07-21,30.98,31.37,30.36,31.07,772026.09,51810407315
|
||||
2025-07-22,30.78,31.45,30.43,30.8,785708.74,51810407315
|
||||
2025-07-23,30.56,30.57,29.89,29.92,703169.55,51810407315
|
||||
2025-07-24,29.84,30.46,29.77,30.31,543627.27,51810407315
|
||||
2025-07-25,30.36,31.13,30.36,30.45,619316.45,51810407315
|
||||
2025-07-28,30.43,31.24,30.18,31.04,702169.14,51810407315
|
||||
2025-07-29,30.79,31.15,30.33,30.69,542088.91,51810407315
|
||||
2025-07-30,30.84,30.85,29.24,29.48,761650.21,51810407315
|
||||
2025-07-31,29.34,30.01,28.94,29.12,470283.8,51810407315
|
||||
2025-08-01,28.99,29.22,28.64,28.67,386407.75,51810407315
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-27,19.98,22.09,19.61,22.09,276542.2,3567378360
|
||||
2025-06-30,21.86,24.19,21.46,23.37,328614.33,3567378360
|
||||
2025-07-01,22.39,22.77,21.46,21.46,246739.02,3567378360
|
||||
2025-07-02,20.91,21.41,20.4,20.88,152920.45,3567378360
|
||||
2025-07-03,20.81,22.49,20.72,22.05,226316.34,3567378360
|
||||
2025-07-04,21.4,21.76,20.75,20.76,157410.92,3567378360
|
||||
2025-07-07,20.47,21.17,20.32,21.0,89066.04,3567378360
|
||||
2025-07-08,21.01,21.11,20.67,20.9,81440.83,3567378360
|
||||
2025-07-09,20.91,21.39,20.42,20.51,94518.29,3567378360
|
||||
2025-07-10,20.51,20.51,19.94,20.32,82854.2,3567378360
|
||||
2025-07-11,20.42,20.6,20.12,20.41,63631.7,3567378360
|
||||
2025-07-14,20.57,20.93,20.51,20.6,71670.49,3567378360
|
||||
2025-07-15,20.45,20.78,20.13,20.47,70849.72,3567378360
|
||||
2025-07-16,20.6,20.86,20.33,20.47,68310.79,3567378360
|
||||
2025-07-17,20.26,20.6,19.92,20.51,62354.29,3567378360
|
||||
2025-07-18,20.48,20.79,20.36,20.49,62896.87,3567378360
|
||||
2025-07-21,20.36,20.97,20.12,20.52,68576.12,3567378360
|
||||
2025-07-22,20.4,21.25,20.34,20.96,129095.1,3567378360
|
||||
2025-07-23,20.84,20.88,20.09,20.17,96276.56,3567378360
|
||||
2025-07-24,20.16,20.35,20.08,20.19,45888.62,3567378360
|
||||
2025-07-25,20.21,20.21,19.97,20.08,38465.12,3567378360
|
||||
2025-07-28,20.09,20.55,20.06,20.4,51218.04,3567378360
|
||||
2025-07-29,20.34,20.54,19.79,19.93,61055.53,3567378360
|
||||
2025-07-30,19.81,20.21,19.2,19.86,79996.39,3567378360
|
||||
2025-07-31,19.66,19.99,19.37,19.48,43501.6,3567378360
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-05,13.86,13.93,13.61,13.82,76839.0,9399394575
|
||||
2025-06-06,13.83,14.01,13.66,13.7,69401.0,9399394575
|
||||
2025-06-09,13.69,13.87,13.64,13.76,75300.24,9399394575
|
||||
2025-06-10,13.7,13.74,12.99,13.18,174574.3,9399394575
|
||||
2025-06-11,13.2,13.34,13.11,13.3,61388.02,9399394575
|
||||
2025-06-12,13.26,13.35,13.14,13.21,46718.0,9399394575
|
||||
2025-06-13,13.17,13.52,13.17,13.38,164443.0,9399394575
|
||||
2025-06-16,13.48,13.75,13.17,13.68,140522.0,9399394575
|
||||
2025-06-17,13.63,14.09,13.62,13.97,143405.8,9399394575
|
||||
2025-06-18,13.98,14.72,13.89,14.72,275552.83,9399394575
|
||||
2025-06-19,14.48,14.48,13.72,14.15,252934.0,9399394575
|
||||
2025-06-20,14.15,14.16,13.75,13.8,127924.0,9399394575
|
||||
2025-06-23,14.01,14.33,13.9,14.33,160493.0,9399394575
|
||||
2025-06-24,14.19,14.87,13.84,14.54,252237.43,9399394575
|
||||
2025-06-25,14.78,16.0,14.71,16.0,600588.02,9399394575
|
||||
2025-06-26,16.0,17.6,15.98,16.63,846170.51,9399394575
|
||||
2025-06-27,16.56,17.27,16.3,16.42,651687.06,9399394575
|
||||
2025-06-30,16.58,17.57,16.58,17.54,612607.43,9399394575
|
||||
2025-07-01,17.28,17.9,16.88,17.24,468426.25,9399394575
|
||||
2025-07-02,17.18,17.18,16.42,16.61,337259.72,9399394575
|
||||
2025-07-03,16.62,16.84,16.37,16.46,199869.31,9399394575
|
||||
2025-07-04,16.37,16.45,16.04,16.1,180557.04,9399394575
|
||||
2025-07-07,16.03,16.32,15.86,16.12,142471.31,9399394575
|
||||
2025-07-08,15.98,16.16,15.91,16.07,122700.83,9399394575
|
||||
2025-07-09,16.08,16.45,15.94,15.99,230184.09,9399394575
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-27,20.43,20.56,20.04,20.27,54029.3,4707136785
|
||||
2025-06-30,20.22,20.5,20.18,20.44,45101.98,4707136785
|
||||
2025-07-01,20.43,20.56,20.05,20.3,46657.31,4707136785
|
||||
2025-07-02,20.28,20.28,19.76,20.03,40227.67,4707136785
|
||||
2025-07-03,20.1,20.18,19.73,19.89,29471.22,4707136785
|
||||
2025-07-04,20.02,20.02,19.43,19.62,32150.52,4707136785
|
||||
2025-07-07,19.61,19.85,19.4,19.78,24560.45,4707136785
|
||||
2025-07-08,19.71,21.16,19.71,20.73,110928.39,4707136785
|
||||
2025-07-09,21.04,21.22,20.32,20.47,80045.04,4707136785
|
||||
2025-07-10,20.25,20.51,19.88,20.01,62948.74,4707136785
|
||||
2025-07-11,19.93,21.34,19.73,21.0,126620.87,4707136785
|
||||
2025-07-14,21.59,24.48,21.11,23.66,270019.12,4707136785
|
||||
2025-07-15,23.57,24.31,23.06,23.93,231598.13,4707136785
|
||||
2025-07-16,23.56,24.31,23.23,23.5,185860.26,4707136785
|
||||
2025-07-17,23.37,24.42,23.03,23.5,162749.05,4707136785
|
||||
2025-07-18,23.37,23.72,22.91,23.16,111343.75,4707136785
|
||||
2025-07-21,23.44,24.61,23.14,24.02,176471.75,4707136785
|
||||
2025-07-22,23.79,23.95,22.83,23.07,132565.5,4707136785
|
||||
2025-07-23,22.9,23.07,22.38,22.71,71180.1,4707136785
|
||||
2025-07-24,22.55,23.17,22.52,22.71,55999.01,4707136785
|
||||
2025-07-25,22.63,22.87,22.42,22.61,54779.45,4707136785
|
||||
2025-07-28,22.97,25.66,22.97,24.61,247298.15,4707136785
|
||||
2025-07-29,24.08,24.45,23.8,24.14,144127.75,4707136785
|
||||
2025-07-30,23.96,24.23,23.15,23.27,110016.38,4707136785
|
||||
2025-07-31,23.07,23.58,22.74,22.87,84262.28,4707136785
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-06,17.64,19.41,17.16,19.41,295488.65,4910064388
|
||||
2025-06-09,20.53,21.35,20.53,21.35,162351.45,4910064388
|
||||
2025-06-10,23.48,23.48,22.95,23.48,96161.81,4910064388
|
||||
2025-06-11,25.5,25.83,24.22,25.83,538547.27,4910064388
|
||||
2025-06-12,27.48,28.07,25.7,26.81,487592.83,4910064388
|
||||
2025-06-13,25.98,26.92,25.23,26.07,302545.06,4910064388
|
||||
2025-06-16,25.81,28.68,25.41,28.68,285190.35,4910064388
|
||||
2025-06-17,30.1,31.49,28.19,28.7,386383.92,4910064388
|
||||
2025-06-18,28.11,28.57,26.72,27.7,314113.19,4910064388
|
||||
2025-06-19,28.59,30.13,27.97,28.73,229020.92,4910064388
|
||||
2025-06-20,26.78,31.6,26.78,31.6,174055.39,4910064388
|
||||
2025-06-23,31.05,31.95,28.44,31.67,234024.79,4910064388
|
||||
2025-06-24,31.07,34.13,31.07,33.05,233411.76,4910064388
|
||||
2025-06-25,31.9,34.7,31.85,32.5,242343.26,4910064388
|
||||
2025-06-26,30.83,32.1,29.28,30.55,193713.48,4910064388
|
||||
2025-06-27,30.57,33.6,30.26,33.6,131012.98,4910064388
|
||||
2025-06-30,33.6,36.97,33.6,36.71,195320.19,4910064388
|
||||
2025-07-01,35.84,40.38,35.83,40.38,161306.24,4910064388
|
||||
2025-07-02,40.35,44.42,39.75,44.42,212138.21,4910064388
|
||||
2025-07-03,40.56,48.08,40.56,44.44,176844.52,4910064388
|
||||
2025-07-04,43.53,44.12,40.49,40.96,138196.86,4910064388
|
||||
2025-07-07,42.76,42.76,39.49,41.12,105590.86,4910064388
|
||||
2025-07-08,41.36,41.5,38.07,39.52,105213.28,4910064388
|
||||
2025-07-09,39.31,40.63,37.92,39.33,97899.1,4910064388
|
||||
2025-07-10,39.46,39.56,36.96,37.43,79650.61,4910064388
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-09-30,7.57,7.76,7.57,7.74,204915.56,22650295811
|
||||
2025-10-09,7.75,7.8,7.68,7.8,196793.96,22650295811
|
||||
2025-10-10,7.79,7.82,7.73,7.75,163527.18,22650295811
|
||||
2025-10-13,7.6,7.8,7.47,7.8,208009.58,22650295811
|
||||
2025-10-14,7.82,7.9,7.73,7.78,203765.38,22650295811
|
||||
2025-10-15,7.77,7.78,7.67,7.75,158196.56,22650295811
|
||||
2025-10-16,7.74,7.76,7.61,7.63,151268.43,22650295811
|
||||
2025-10-17,7.62,7.72,7.47,7.48,162246.05,22650295811
|
||||
2025-10-20,7.55,7.61,7.51,7.58,122212.11,22650295811
|
||||
2025-10-21,7.58,7.67,7.56,7.64,121825.06,22650295811
|
||||
2025-10-22,7.64,7.86,7.58,7.82,322717.34,22650295811
|
||||
2025-10-23,7.8,7.82,7.67,7.81,170156.0,22650295811
|
||||
2025-10-24,8.1,8.3,7.95,7.98,615299.42,22650295811
|
||||
2025-10-27,8.0,8.18,7.94,8.04,433223.34,22650295811
|
||||
2025-10-28,7.99,8.84,7.97,8.84,1610159.98,22650295811
|
||||
2025-10-29,8.6,9.0,8.41,8.75,1722050.97,22650295811
|
||||
2025-10-30,8.7,8.82,8.51,8.6,1035934.51,22650295811
|
||||
2025-10-31,8.57,8.62,8.34,8.37,686044.09,22650295811
|
||||
2025-11-03,8.37,8.61,8.33,8.6,748009.31,22650295811
|
||||
2025-11-04,8.52,9.26,8.5,8.98,1365750.29,22650295811
|
||||
2025-11-05,8.76,8.91,8.67,8.81,823459.67,22650295811
|
||||
2025-11-06,8.77,8.8,8.6,8.65,553188.01,22650295811
|
||||
2025-11-07,8.67,8.75,8.56,8.67,592496.69,22650295811
|
||||
2025-11-10,8.74,8.79,8.51,8.53,513705.42,22650295811
|
||||
2025-11-11,8.48,8.54,8.39,8.47,440314.81,22650295811
|
||||
|
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"algorithm": {
|
||||
"version": "zhixing_b1_pattern_fastdtw_v1",
|
||||
"radius": 1,
|
||||
"distance": "scalar_euclidean",
|
||||
"lookback_days": 25,
|
||||
"threshold": 60.0,
|
||||
"weights": [0.10, 0.20, 0.25, 0.45]
|
||||
},
|
||||
"self_match": {
|
||||
"status": "matched",
|
||||
"value": 95.0,
|
||||
"case_id": "case_001",
|
||||
"breakdown": {
|
||||
"trend_structure": 50.0,
|
||||
"kdj_state": 100.0,
|
||||
"volume_pattern": 100.0,
|
||||
"price_shape": 100.0
|
||||
}
|
||||
},
|
||||
"time_warped": {
|
||||
"status": "matched",
|
||||
"value": 78.38,
|
||||
"case_id": "case_001",
|
||||
"breakdown": {
|
||||
"trend_structure": 44.51,
|
||||
"kdj_state": 77.15,
|
||||
"volume_pattern": 65.0,
|
||||
"price_shape": 93.89
|
||||
}
|
||||
},
|
||||
"below_threshold": {
|
||||
"status": "below_threshold",
|
||||
"value": 46.27,
|
||||
"case_id": "case_010",
|
||||
"breakdown": {
|
||||
"trend_structure": 28.33,
|
||||
"kdj_state": 91.79,
|
||||
"volume_pattern": 27.5,
|
||||
"price_shape": 40.45
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ def test_postgres_migration_creates_market_data_contract(
|
||||
engine: Engine = create_engine(sqlalchemy_url)
|
||||
command.upgrade(config, "head")
|
||||
try:
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
inspector = inspect(engine)
|
||||
tables = set(inspector.get_table_names())
|
||||
assert {
|
||||
"market_stock",
|
||||
"market_daily_bar",
|
||||
@@ -39,6 +40,25 @@ def test_postgres_migration_creates_market_data_contract(
|
||||
"selection_run_item",
|
||||
"selection_signal",
|
||||
} <= tables
|
||||
item_columns = {column["name"] for column in inspector.get_columns("selection_run_item")}
|
||||
assert {
|
||||
"score_status",
|
||||
"score_value",
|
||||
"score_threshold",
|
||||
"score_version",
|
||||
"match_case_id",
|
||||
"match_case_name",
|
||||
"match_case_breakout_date",
|
||||
"match_breakdown",
|
||||
"score_reason",
|
||||
} <= item_columns
|
||||
constraint_names = {
|
||||
constraint["name"]
|
||||
for constraint in inspector.get_check_constraints("selection_run_item")
|
||||
}
|
||||
assert "ck_selection_run_item_breakdown_range" in constraint_names
|
||||
index_names = {index["name"] for index in inspector.get_indexes("selection_run_item")}
|
||||
assert "ix_selection_run_item_score" in index_names
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""HTTP contracts for triggering and querying persisted selection runs."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -11,6 +12,12 @@ from zhixing_server.bootstrap.app import create_app
|
||||
from zhixing_server.bootstrap.config import Settings
|
||||
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -130,6 +137,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
|
||||
name="平安银行",
|
||||
status="selected",
|
||||
signal_count=2,
|
||||
pattern_score=PatternScore(
|
||||
status="matched",
|
||||
value=86.4,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
|
||||
),
|
||||
signals=(original_signal, pullback_signal),
|
||||
),
|
||||
),
|
||||
@@ -238,6 +253,24 @@ def test_query_returns_persisted_signal_details() -> None:
|
||||
assert "signals" not in body
|
||||
assert len(body["stocks"]) == 1
|
||||
assert body["stocks"][0]["ts_code"] == "000001.SZ"
|
||||
assert body["stocks"][0]["score"] == {
|
||||
"status": "matched",
|
||||
"value": 86.4,
|
||||
"threshold": 60.0,
|
||||
"version": PATTERN_SCORING_VERSION,
|
||||
"case": {
|
||||
"id": "case_001",
|
||||
"name": "华纳药厂",
|
||||
"breakout_date": "2025-05-12",
|
||||
},
|
||||
"breakdown": {
|
||||
"trend_structure": 71.2,
|
||||
"kdj_state": 83.0,
|
||||
"volume_pattern": 88.0,
|
||||
"price_shape": 90.1,
|
||||
},
|
||||
"reason": None,
|
||||
}
|
||||
assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [
|
||||
"zhixing_b1_original_b1",
|
||||
"zhixing_b1_pullback_white",
|
||||
@@ -248,6 +281,59 @@ def test_query_returns_persisted_signal_details() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pattern_score", "expected_score"),
|
||||
[
|
||||
(
|
||||
PatternScore(
|
||||
status="below_threshold",
|
||||
value=42.5,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(40.0, 42.0, 43.0, 44.0),
|
||||
),
|
||||
{
|
||||
"status": "below_threshold",
|
||||
"value": None,
|
||||
"threshold": 60.0,
|
||||
"version": PATTERN_SCORING_VERSION,
|
||||
"case": None,
|
||||
"breakdown": None,
|
||||
"reason": "未匹配到评分阈值以上案例",
|
||||
},
|
||||
),
|
||||
(
|
||||
PatternScore.failed("FastDTW unavailable"),
|
||||
{
|
||||
"status": "failed",
|
||||
"value": None,
|
||||
"threshold": None,
|
||||
"version": None,
|
||||
"case": None,
|
||||
"breakdown": None,
|
||||
"reason": "FastDTW unavailable",
|
||||
},
|
||||
),
|
||||
(PatternScore(), None),
|
||||
],
|
||||
)
|
||||
def test_query_preserves_signals_for_every_pattern_score_state(
|
||||
pattern_score: PatternScore,
|
||||
expected_score: dict[str, object] | None,
|
||||
) -> None:
|
||||
run = _run("run-http", "success")
|
||||
run = replace(run, items=(replace(run.items[0], pattern_score=pattern_score),))
|
||||
|
||||
response = _client(FakeSelectionService(run)).get("/api/v1/selection/results")
|
||||
|
||||
assert response.status_code == 200
|
||||
stock = response.json()["stocks"][0]
|
||||
assert stock["score"] == expected_score
|
||||
assert len(stock["signals"]) == 2
|
||||
assert response.json()["failures"] == []
|
||||
|
||||
|
||||
def test_query_forwards_pagination_and_filters() -> None:
|
||||
service = FakeSelectionService(_run("run-http", "success"))
|
||||
|
||||
@@ -259,6 +345,7 @@ def test_query_forwards_pagination_and_filters() -> None:
|
||||
"page_size": 5,
|
||||
"search": " 平安银行 ",
|
||||
"category": "original",
|
||||
"sort": "score_desc",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -268,6 +355,7 @@ def test_query_forwards_pagination_and_filters() -> None:
|
||||
page_size=5,
|
||||
search="平安银行",
|
||||
category="original",
|
||||
sort="score_desc",
|
||||
)
|
||||
assert response.json()["page"] == 2
|
||||
assert response.json()["page_size"] == 5
|
||||
@@ -282,6 +370,18 @@ def test_query_rejects_invalid_page_size() -> None:
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_query_forwards_score_ascending_sort() -> None:
|
||||
service = FakeSelectionService(_run("run-http", "success"))
|
||||
|
||||
response = _client(service).get(
|
||||
"/api/v1/selection/results",
|
||||
params={"strategy": "zhixing_b1", "sort": "score_asc"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert service.last_query == SelectionResultQuery(sort="score_asc")
|
||||
|
||||
|
||||
def test_run_polling_returns_the_persisted_terminal_result() -> None:
|
||||
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
|
||||
"/api/v1/selection/runs/run-http"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Golden and invariant tests for versioned B1 FastDTW scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_FASTDTW_RADIUS,
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternCase,
|
||||
PatternCaseLibraryError,
|
||||
PatternFeatures,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
PatternScoringError,
|
||||
ZhixingB1PatternScorer,
|
||||
build_pattern_case,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parents[2] / "fixtures" / "selection" / "zhixing_b1" / "pattern_scoring"
|
||||
|
||||
|
||||
def _history(case_id: str, ts_code: str, name: str) -> StockHistory:
|
||||
frame = pd.read_csv(FIXTURES / f"{case_id}.csv")
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=date.fromisoformat(str(row.date)),
|
||||
open=float(str(row.open)),
|
||||
high=float(str(row.high)),
|
||||
low=float(str(row.low)),
|
||||
close=float(str(row.close)),
|
||||
volume=float(str(row.volume)),
|
||||
)
|
||||
for row in frame.itertuples(index=False)
|
||||
)
|
||||
return StockHistory(ts_code=ts_code, name=name, bars=bars)
|
||||
|
||||
|
||||
def _cases() -> tuple[PatternCase, ...]:
|
||||
return tuple(
|
||||
build_pattern_case(
|
||||
definition,
|
||||
_history(definition.id, definition.ts_code, definition.name),
|
||||
)
|
||||
for definition in ZHIXING_B1_PATTERN_CASES
|
||||
)
|
||||
|
||||
|
||||
def _golden(name: str) -> dict[str, object]:
|
||||
payload = cast(dict[str, object], json.loads((FIXTURES / "golden.json").read_text()))
|
||||
return cast(dict[str, object], payload[name])
|
||||
|
||||
|
||||
def _assert_golden(score: PatternScore, expected: dict[str, object]) -> None:
|
||||
assert score.status == expected["status"]
|
||||
assert score.value == expected["value"]
|
||||
assert score.case is not None
|
||||
assert score.case.id == expected["case_id"]
|
||||
assert score.breakdown is not None
|
||||
assert score.breakdown.as_dict() == expected["breakdown"]
|
||||
|
||||
|
||||
def test_fastdtw_v1_self_match_golden_is_finite_and_deterministic() -> None:
|
||||
cases = _cases()
|
||||
scorer = ZhixingB1PatternScorer()
|
||||
|
||||
first = scorer.score(cases[0].history, cases)
|
||||
second = scorer.score(cases[0].history, cases)
|
||||
|
||||
assert PATTERN_SCORING_VERSION == "zhixing_b1_pattern_fastdtw_v1"
|
||||
assert PATTERN_FASTDTW_RADIUS == 1
|
||||
assert first == second
|
||||
_assert_golden(first, _golden("self_match"))
|
||||
assert cases[0].features.trend_structure["short_vs_bullbear"] is None
|
||||
|
||||
|
||||
def test_fastdtw_v1_time_warped_curve_golden() -> None:
|
||||
cases = _cases()
|
||||
base = cases[0].history.bars
|
||||
delayed = base[:1] * 3 + base[:-3]
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=base[index].trade_date,
|
||||
open=delayed[index].open,
|
||||
high=delayed[index].high,
|
||||
low=delayed[index].low,
|
||||
close=delayed[index].close,
|
||||
volume=delayed[index].volume,
|
||||
)
|
||||
for index in range(25)
|
||||
)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(
|
||||
StockHistory(ts_code="TEST.SZ", name="time warped", bars=bars),
|
||||
cases,
|
||||
)
|
||||
|
||||
_assert_golden(result, _golden("time_warped"))
|
||||
|
||||
|
||||
def test_below_threshold_golden_remains_a_successful_computation() -> None:
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=date(2026, 1, 1) + timedelta(days=index),
|
||||
open=100.0 if index % 2 == 0 else 1.0,
|
||||
high=110.0,
|
||||
low=0.9,
|
||||
close=1.0 if index % 2 == 0 else 100.0,
|
||||
volume=1.0 if index < 13 else 1_000_000.0,
|
||||
)
|
||||
for index in range(25)
|
||||
)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(
|
||||
StockHistory(ts_code="TEST.SZ", name="below", bars=bars),
|
||||
_cases(),
|
||||
)
|
||||
|
||||
_assert_golden(result, _golden("below_threshold"))
|
||||
|
||||
|
||||
def test_case_library_rejects_partial_or_short_input() -> None:
|
||||
cases = _cases()
|
||||
with pytest.raises(PatternScoringError, match="incomplete or out of order"):
|
||||
ZhixingB1PatternScorer().score(cases[0].history, cases[:-1])
|
||||
|
||||
definition = ZHIXING_B1_PATTERN_CASES[0]
|
||||
short = _history(definition.id, definition.ts_code, definition.name)
|
||||
with pytest.raises(PatternCaseLibraryError, match="requires 25 complete rows"):
|
||||
build_pattern_case(
|
||||
definition,
|
||||
StockHistory(short.ts_code, short.name, short.bars[:-1]),
|
||||
)
|
||||
|
||||
|
||||
def test_fastdtw_failure_is_not_replaced_by_simple_dtw(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
|
||||
|
||||
def fail(*_args: object, **_kwargs: object) -> tuple[float, list[tuple[int, int]]]:
|
||||
raise RuntimeError("fastdtw unavailable")
|
||||
|
||||
monkeypatch.setattr(scoring, "_fastdtw", lambda: fail)
|
||||
|
||||
with pytest.raises(RuntimeError, match="fastdtw unavailable"):
|
||||
ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
|
||||
|
||||
|
||||
def test_failed_score_requires_a_safe_reason() -> None:
|
||||
with pytest.raises(ValueError, match="requires a safe reason"):
|
||||
PatternScore(status="failed")
|
||||
|
||||
assert PatternScore.failed(" ").reason == "pattern scoring failed"
|
||||
|
||||
|
||||
def test_threshold_is_inclusive_and_equal_scores_keep_first_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
|
||||
|
||||
tied = PatternScoreBreakdown(60.0, 60.0, 60.0, 60.0)
|
||||
|
||||
def tied_match(
|
||||
_candidate: PatternFeatures,
|
||||
_case: PatternFeatures,
|
||||
) -> PatternScoreBreakdown:
|
||||
return tied
|
||||
|
||||
monkeypatch.setattr(scoring, "_match", tied_match)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
|
||||
|
||||
assert result.status == "matched"
|
||||
assert result.value == 60.0
|
||||
assert result.case == ZHIXING_B1_PATTERN_CASES[0]
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import ZHIXING_B1_PATTERN_CASES
|
||||
from zhixing_server.modules.selection.domain.runs import SelectionStock
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||
PostgresMarketDataReader,
|
||||
PostgresPatternCaseLibraryLoader,
|
||||
SelectionMarketDataNotReady,
|
||||
)
|
||||
|
||||
@@ -237,3 +239,34 @@ def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.M
|
||||
"zhixing_b1",
|
||||
date(2026, 8, 8),
|
||||
)
|
||||
|
||||
|
||||
def test_pattern_case_loader_reads_one_complete_exclusive_qfq_library() -> None:
|
||||
rows: list[tuple[object, ...]] = []
|
||||
for definition in ZHIXING_B1_PATTERN_CASES:
|
||||
for offset in range(definition.lookback_days, 0, -1):
|
||||
rows.append(
|
||||
(
|
||||
definition.id,
|
||||
definition.ts_code,
|
||||
definition.breakout_date - timedelta(days=offset),
|
||||
"10",
|
||||
"11",
|
||||
"9",
|
||||
str(10 + offset / 100),
|
||||
str(1000 + offset),
|
||||
)
|
||||
)
|
||||
connection = FakeConnection(rows)
|
||||
pool = Pool(connection)
|
||||
owner = SelectionPostgresPool("postgresql://test", max_connections=2, pool=pool)
|
||||
|
||||
cases = PostgresPatternCaseLibraryLoader("postgresql://test", pool=owner).load()
|
||||
|
||||
assert tuple(case.definition for case in cases) == ZHIXING_B1_PATTERN_CASES
|
||||
assert all(len(case.history.bars) == 25 for case in cases)
|
||||
assert all(case.history.bars[-1].trade_date < case.definition.breakout_date for case in cases)
|
||||
assert "bar.trade_date < definition.breakout_date" in cast(str, connection.query)
|
||||
assert "bar.source_adj = 'qfq'" in cast(str, connection.query)
|
||||
assert connection.parameters is not None
|
||||
assert connection.parameters[0] == [definition.id for definition in ZHIXING_B1_PATTERN_CASES]
|
||||
|
||||
@@ -8,6 +8,12 @@ import pytest
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -222,6 +228,14 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
|
||||
name="平安银行",
|
||||
status="selected",
|
||||
signal_count=2,
|
||||
pattern_score=PatternScore(
|
||||
status="matched",
|
||||
value=86.4,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
|
||||
),
|
||||
signals=(first, second),
|
||||
),
|
||||
SelectionRunItem(
|
||||
@@ -238,6 +252,28 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
|
||||
assert delete_parameters == ("run-1", ["000001.SZ", "600000.SH"])
|
||||
assert len(connection.executemany_calls) == 2
|
||||
assert "INSERT INTO selection_run_item" in connection.executemany_calls[0][0]
|
||||
item_parameters = connection.executemany_calls[0][1]
|
||||
assert item_parameters[0][6:13] == (
|
||||
"matched",
|
||||
86.4,
|
||||
60.0,
|
||||
PATTERN_SCORING_VERSION,
|
||||
"case_001",
|
||||
"华纳药厂",
|
||||
date(2025, 5, 12),
|
||||
)
|
||||
assert isinstance(item_parameters[0][13], Jsonb)
|
||||
assert item_parameters[1][6:] == (
|
||||
"not_executed",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert "INSERT INTO selection_signal" in connection.executemany_calls[1][0]
|
||||
signal_parameters = connection.executemany_calls[1][1]
|
||||
assert len(signal_parameters) == 2
|
||||
@@ -281,11 +317,35 @@ class LoadConnection:
|
||||
None,
|
||||
)
|
||||
)
|
||||
if "FROM selection_run_item" in query:
|
||||
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
|
||||
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query:
|
||||
if "FROM selection_run_item\n" in query:
|
||||
return LoadResult(
|
||||
rows=[
|
||||
(
|
||||
"000001.SZ",
|
||||
"平安银行",
|
||||
"selected",
|
||||
2,
|
||||
None,
|
||||
"matched",
|
||||
Decimal("86.40"),
|
||||
Decimal("60.00"),
|
||||
PATTERN_SCORING_VERSION,
|
||||
"case_001",
|
||||
"华纳药厂",
|
||||
date(2025, 5, 12),
|
||||
{
|
||||
"trend_structure": 71.2,
|
||||
"kdj_state": 83.0,
|
||||
"volume_pattern": 88.0,
|
||||
"price_shape": 90.1,
|
||||
},
|
||||
None,
|
||||
)
|
||||
]
|
||||
)
|
||||
if "SELECT COUNT(*) FROM selection_run_item AS item" in query:
|
||||
return LoadResult(row=(2,))
|
||||
if "SELECT DISTINCT ts_code" in query:
|
||||
if "SELECT item.ts_code" in query:
|
||||
return LoadResult(rows=[("000001.SZ",)])
|
||||
return LoadResult(
|
||||
rows=[
|
||||
@@ -315,7 +375,7 @@ class EmptyStockPageConnection(LoadConnection):
|
||||
"""Return a non-zero filtered total with no stocks on the requested page."""
|
||||
|
||||
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
|
||||
if "SELECT DISTINCT ts_code" in query:
|
||||
if "SELECT item.ts_code" in query:
|
||||
self.statements.append((query, parameters))
|
||||
return LoadResult(rows=[])
|
||||
return super().execute(query, parameters)
|
||||
@@ -355,6 +415,7 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
|
||||
page_size=1,
|
||||
search="100%",
|
||||
category="pullback",
|
||||
sort="score_desc",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -364,19 +425,21 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
|
||||
ZHIXING_B1_SIGNAL_ORDER[-1],
|
||||
]
|
||||
assert run.stocks_total == 2
|
||||
assert run.items[0].pattern_score.status == "matched"
|
||||
assert run.items[0].pattern_score.value == 86.4
|
||||
count_query, count_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query
|
||||
if "SELECT COUNT(*) FROM selection_run_item AS item" in query
|
||||
)
|
||||
assert "name ILIKE %s ESCAPE" in count_query
|
||||
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
|
||||
stock_page_query, page_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "SELECT DISTINCT ts_code" in query
|
||||
if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY ts_code" in stock_page_query
|
||||
assert "ORDER BY item.score_value DESC NULLS LAST, item.ts_code ASC" in stock_page_query
|
||||
assert page_parameters[-2:] == (1, 0)
|
||||
signal_query, signal_parameters = next(
|
||||
(query, parameters)
|
||||
@@ -409,8 +472,30 @@ def test_get_run_does_not_load_signals_for_an_empty_stock_page(
|
||||
stock_page_query, stock_page_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "SELECT DISTINCT ts_code" in query
|
||||
if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY ts_code" in stock_page_query
|
||||
assert "ORDER BY item.ts_code ASC" in stock_page_query
|
||||
assert stock_page_parameters[-2:] == (1, 2)
|
||||
assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements)
|
||||
|
||||
|
||||
def test_get_run_sorts_scores_ascending_with_nulls_last_and_code_tiebreak(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
connection = LoadConnection()
|
||||
|
||||
def connect(database_url: str) -> LoadConnection:
|
||||
assert database_url == "postgresql://test"
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(psycopg, "connect", connect)
|
||||
run = PostgresSelectionRunRepository("postgresql://test").get_run(
|
||||
"run-1",
|
||||
query=SelectionResultQuery(sort="score_asc"),
|
||||
)
|
||||
|
||||
assert run is not None
|
||||
stock_page_query = next(
|
||||
query for query, _ in connection.statements if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY item.score_value ASC NULLS LAST, item.ts_code ASC" in stock_page_query
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
@@ -15,6 +16,14 @@ from zhixing_server.modules.selection.domain.models import (
|
||||
SelectionSignal,
|
||||
StockHistory,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORE_THRESHOLD,
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternCase,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionResultQuery,
|
||||
@@ -198,6 +207,37 @@ class ConcurrentHistoryEvaluator:
|
||||
return SelectionEvaluation(history.ts_code, target_trade_date, "no_signal")
|
||||
|
||||
|
||||
class FakePatternCaseLoader:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.calls = 0
|
||||
self.error = error
|
||||
|
||||
def load(self) -> tuple[PatternCase, ...]:
|
||||
self.calls += 1
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return ()
|
||||
|
||||
|
||||
class FakePatternScorer:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.error = error
|
||||
|
||||
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore:
|
||||
self.calls.append(history.ts_code)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return PatternScore(
|
||||
status="matched",
|
||||
value=88.0,
|
||||
threshold=PATTERN_SCORE_THRESHOLD,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(80.0, 85.0, 90.0, 88.0),
|
||||
)
|
||||
|
||||
|
||||
def _source() -> SelectionExecutionSource:
|
||||
return SelectionExecutionSource(
|
||||
market_sync_batch_id="market-run-1",
|
||||
@@ -364,3 +404,149 @@ def test_execute_marks_batch_write_failure_as_failed() -> None:
|
||||
assert store.finished[0:2] == ("run-1", "failed")
|
||||
assert store.finished[2]["error_type"] == "batch_error"
|
||||
assert store.finished[2]["failed_count"] == 1
|
||||
|
||||
|
||||
def test_execute_loads_cases_once_and_scores_only_selected_stocks() -> None:
|
||||
source = _source()
|
||||
reader = BatchReader(source)
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader()
|
||||
scorer = FakePatternScorer()
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
reader,
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 1
|
||||
assert scorer.calls == ["000001.SZ"]
|
||||
assert [item.pattern_score.status for item in store.items] == ["matched", "not_executed"]
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_isolates_pattern_scoring_failure_from_selection_status() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader()
|
||||
scorer = FakePatternScorer(error=RuntimeError("FastDTW unavailable"))
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert store.items[0].status == "selected"
|
||||
assert store.items[0].pattern_score == PatternScore.failed("FastDTW unavailable")
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_skips_pattern_dependencies_when_feature_flag_is_disabled() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader(error=AssertionError("loader must not run"))
|
||||
scorer = FakePatternScorer(error=AssertionError("scorer must not run"))
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=False,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 0
|
||||
assert scorer.calls == []
|
||||
assert [item.pattern_score.status for item in store.items] == [
|
||||
"not_executed",
|
||||
"not_executed",
|
||||
]
|
||||
assert store.items[0].signal_count == 1
|
||||
assert store.finished is not None
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_marks_scores_failed_when_case_library_is_unavailable() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader(error=RuntimeError("case_011 requires 25 qfq rows"))
|
||||
scorer = FakePatternScorer()
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 1
|
||||
assert scorer.calls == []
|
||||
assert store.items[0].status == "selected"
|
||||
assert store.items[0].pattern_score.status == "failed"
|
||||
assert store.items[0].signals[0].category.value == "zhixing_b1_original_b1"
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
Generated
+11
@@ -169,6 +169,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastdtw"
|
||||
version = "0.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/30f2d8db076f216b15c10db663b46e22d1750b1ebacd7af6e62b83d6ab98/fastdtw-0.3.4.tar.gz", hash = "sha256:2350fa6ec36bcad186eaf81f46eff35181baf04e324f522de8aeb43d0243f64f", size = 133402, upload-time = "2019-10-07T16:02:29.982Z" }
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.4"
|
||||
@@ -892,6 +901,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastdtw" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
@@ -915,6 +925,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.0" },
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "fastdtw", specifier = ">=0.3.4" },
|
||||
{ name = "numpy", specifier = ">=2.4.0" },
|
||||
{ name = "pandas", specifier = ">=2.3.3" },
|
||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
|
||||
|
||||
Reference in New Issue
Block a user