feat(selection): 集成 B1 FastDTW 图形评分
This commit is contained in:
+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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user