906 lines
39 KiB
Python
906 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from backend.features.sentiment.engine import build_sentiment_history
|
|
|
|
|
|
def _optional_float(value: Any) -> float | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class ScreenerRepositoryMixin:
|
|
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
|
|
values = [
|
|
(
|
|
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
|
|
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
|
|
)
|
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
|
]
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
close=excluded.close, pct_chg=excluded.pct_chg
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
|
values = [
|
|
(
|
|
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
|
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
|
|
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
|
|
_optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
|
|
_optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
|
|
)
|
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
|
]
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO daily_indicators
|
|
(trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
|
|
pe_ttm, pb, ps_ttm, dv_ttm)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
|
|
total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
|
|
pe_ttm=excluded.pe_ttm, pb=excluded.pb,
|
|
ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
|
|
values = [
|
|
(
|
|
str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
|
|
str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
|
|
_optional_float(row.get("roa")), _optional_float(row.get("roic")),
|
|
_optional_float(row.get("grossprofit_margin")),
|
|
_optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
|
|
_optional_float(row.get("ocf_to_opincome")),
|
|
)
|
|
for row in rows
|
|
if row.get("end_date") and row.get("ts_code")
|
|
]
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO fundamental_indicators
|
|
(end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
|
|
netprofit_yoy, or_yoy, ocf_to_opincome)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(end_date, ts_code) DO UPDATE SET
|
|
ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
|
|
roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
|
|
netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
|
|
ocf_to_opincome=excluded.ocf_to_opincome
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int:
|
|
values = []
|
|
for row in rows:
|
|
if not row.get("trade_date") or not row.get("ts_code"):
|
|
continue
|
|
large_net = (
|
|
float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0)
|
|
- float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0)
|
|
)
|
|
medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0)
|
|
small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0)
|
|
values.append((
|
|
str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0),
|
|
large_net, medium_net, small_net,
|
|
))
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO moneyflow_daily
|
|
(trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount,
|
|
medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
|
|
values = [
|
|
(
|
|
str(row.get("end_date") or ""),
|
|
str(row.get("ann_date") or ""),
|
|
str(row.get("ts_code") or ""),
|
|
_optional_float(row.get("forecast_profit")),
|
|
_optional_float(row.get("actual_profit")),
|
|
_optional_float(row.get("surprise_pct")),
|
|
_optional_float(row.get("revenue_yoy")),
|
|
_optional_float(row.get("netprofit_yoy")),
|
|
str(row.get("source") or ""),
|
|
)
|
|
for row in rows
|
|
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
|
|
]
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO earnings_events
|
|
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
|
|
surprise_pct, revenue_yoy, netprofit_yoy, source)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
|
|
forecast_profit=excluded.forecast_profit,
|
|
actual_profit=excluded.actual_profit,
|
|
surprise_pct=excluded.surprise_pct,
|
|
revenue_yoy=excluded.revenue_yoy,
|
|
netprofit_yoy=excluded.netprofit_yoy,
|
|
source=excluded.source
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
|
|
where = "WHERE trade_date <= ?" if end_date else ""
|
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
|
|
"ORDER BY trade_date DESC LIMIT ?",
|
|
parameters,
|
|
).fetchall()
|
|
return [row["trade_date"] for row in reversed(rows)]
|
|
|
|
def fundamental_periods(self) -> list[str]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
|
|
).fetchall()
|
|
return [str(row["end_date"]) for row in rows]
|
|
|
|
def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
|
where = "WHERE trade_date <= ?" if end_date else ""
|
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?",
|
|
parameters,
|
|
).fetchall()
|
|
return [row["trade_date"] for row in reversed(rows)]
|
|
|
|
def factor_health_summary(self, end_date: str) -> dict[str, Any]:
|
|
dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
|
|
with self.connect() as connection:
|
|
market = connection.execute(
|
|
"SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
auction = connection.execute(
|
|
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
benchmark_rows = connection.execute(
|
|
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
indicator_date = connection.execute(
|
|
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
if indicator_date:
|
|
valuation_rows, valuation_available = connection.execute(
|
|
"""
|
|
SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
|
|
FROM daily_indicators WHERE trade_date = ?
|
|
""",
|
|
(indicator_date,),
|
|
).fetchone()
|
|
else:
|
|
valuation_rows, valuation_available = 0, 0
|
|
dividend_years = connection.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
|
|
FROM daily_indicators
|
|
WHERE trade_date <= ? AND trade_date >= ?
|
|
""",
|
|
(end_date, dividend_start),
|
|
).fetchone()[0]
|
|
fundamental_rows = connection.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM fundamental_indicators fi
|
|
INNER JOIN (
|
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
|
FROM fundamental_indicators
|
|
WHERE ann_date = '' OR ann_date <= ?
|
|
GROUP BY ts_code
|
|
) latest
|
|
ON latest.ts_code = fi.ts_code
|
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
|
""",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
moneyflow_dates = connection.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT trade_date)
|
|
FROM moneyflow_daily
|
|
WHERE trade_date IN (
|
|
SELECT DISTINCT trade_date
|
|
FROM daily_bars
|
|
WHERE trade_date <= ?
|
|
ORDER BY trade_date DESC
|
|
LIMIT 5
|
|
)
|
|
""",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
earnings_rows = connection.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM earnings_events
|
|
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
|
|
""",
|
|
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
|
|
).fetchone()[0]
|
|
popularity_rows = connection.execute(
|
|
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
institution_rows = connection.execute(
|
|
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
|
|
(end_date,),
|
|
).fetchone()[0]
|
|
return {
|
|
"market": bool(market),
|
|
"auction": bool(auction),
|
|
"benchmark": int(benchmark_rows or 0) >= 60,
|
|
"benchmark_rows": int(benchmark_rows or 0),
|
|
"valuation": bool(valuation_available),
|
|
"fundamental": int(fundamental_rows or 0) >= 100,
|
|
"dividend_history": int(dividend_years or 0) >= 4,
|
|
"valuation_rows": int(valuation_rows or 0),
|
|
"fundamental_rows": int(fundamental_rows or 0),
|
|
"dividend_years": int(dividend_years or 0),
|
|
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
|
"moneyflow_dates": int(moneyflow_dates or 0),
|
|
"earnings_events": int(earnings_rows or 0) > 0,
|
|
"earnings_event_rows": int(earnings_rows or 0),
|
|
"popularity": int(popularity_rows or 0) > 0,
|
|
"popularity_rows": int(popularity_rows or 0),
|
|
"institutions": int(institution_rows or 0) > 0,
|
|
"institution_rows": int(institution_rows or 0),
|
|
}
|
|
|
|
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
|
dates = self.factor_dates(end_date, limit_dates)
|
|
if not dates:
|
|
return {
|
|
"dates": [], "bars": [], "master": [], "indicators": [],
|
|
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
|
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
|
"benchmarks": [], "fundamental_history": [],
|
|
"earnings_events": [], "popularity": [], "institutions": [],
|
|
}
|
|
placeholders = ",".join("?" for _ in dates)
|
|
with self.connect() as connection:
|
|
bars = connection.execute(
|
|
f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code",
|
|
dates,
|
|
).fetchall()
|
|
master = connection.execute("SELECT * FROM stock_master").fetchall()
|
|
indicators = connection.execute(
|
|
"""
|
|
SELECT * FROM daily_indicators
|
|
WHERE trade_date = (
|
|
SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?
|
|
)
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
indicator_history = connection.execute(
|
|
"""
|
|
SELECT di.* FROM daily_indicators di
|
|
INNER JOIN (
|
|
SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
|
|
MAX(trade_date) AS max_date
|
|
FROM daily_indicators
|
|
WHERE trade_date <= ? AND trade_date >= ?
|
|
GROUP BY ts_code, substr(trade_date, 1, 4)
|
|
) latest
|
|
ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
|
|
ORDER BY di.trade_date, di.ts_code
|
|
""",
|
|
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
|
).fetchall()
|
|
indicator_series = connection.execute(
|
|
f"""
|
|
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
|
|
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
|
|
FROM daily_indicators
|
|
WHERE trade_date IN ({placeholders})
|
|
ORDER BY trade_date, ts_code
|
|
""",
|
|
dates,
|
|
).fetchall()
|
|
fundamentals = connection.execute(
|
|
"""
|
|
SELECT fi.* FROM fundamental_indicators fi
|
|
INNER JOIN (
|
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
|
FROM fundamental_indicators
|
|
WHERE ann_date = '' OR ann_date <= ?
|
|
GROUP BY ts_code
|
|
) latest
|
|
ON latest.ts_code = fi.ts_code
|
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
fundamental_history = connection.execute(
|
|
"""
|
|
SELECT * FROM fundamental_indicators
|
|
WHERE ann_date = '' OR ann_date <= ?
|
|
ORDER BY ann_date, end_date, ts_code
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
moneyflow = connection.execute(
|
|
"""
|
|
SELECT * FROM moneyflow_daily
|
|
WHERE trade_date = (
|
|
SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ?
|
|
)
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
flow_dates = dates[-min(5, len(dates)):]
|
|
flow_placeholders = ",".join("?" for _ in flow_dates)
|
|
moneyflow_history = connection.execute(
|
|
f"""
|
|
SELECT * FROM moneyflow_daily
|
|
WHERE trade_date IN ({flow_placeholders})
|
|
ORDER BY trade_date, ts_code
|
|
""",
|
|
flow_dates,
|
|
).fetchall()
|
|
auction = connection.execute(
|
|
"""
|
|
SELECT * FROM auction_factors
|
|
WHERE trade_date = (
|
|
SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ?
|
|
)
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
benchmarks = connection.execute(
|
|
f"""
|
|
SELECT * FROM benchmark_bars
|
|
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
|
|
ORDER BY trade_date
|
|
""",
|
|
dates,
|
|
).fetchall()
|
|
earnings_events = connection.execute(
|
|
"""
|
|
SELECT * FROM earnings_events
|
|
WHERE ann_date <= ?
|
|
ORDER BY ann_date, end_date, ts_code
|
|
""",
|
|
(end_date,),
|
|
).fetchall()
|
|
popularity = connection.execute(
|
|
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
|
|
(end_date,),
|
|
).fetchall()
|
|
institutions = connection.execute(
|
|
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
|
|
(end_date,),
|
|
).fetchall()
|
|
return {
|
|
"dates": dates,
|
|
"bars": [dict(row) for row in bars],
|
|
"master": [dict(row) for row in master],
|
|
"indicators": [dict(row) for row in indicators],
|
|
"indicator_history": [dict(row) for row in indicator_history],
|
|
"indicator_series": [dict(row) for row in indicator_series],
|
|
"fundamentals": [dict(row) for row in fundamentals],
|
|
"fundamental_history": [dict(row) for row in fundamental_history],
|
|
"moneyflow": [dict(row) for row in moneyflow],
|
|
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
|
"auction": [dict(row) for row in auction],
|
|
"benchmarks": [dict(row) for row in benchmarks],
|
|
"earnings_events": [dict(row) for row in earnings_events],
|
|
"popularity": [dict(row) for row in popularity],
|
|
"institutions": [dict(row) for row in institutions],
|
|
}
|
|
|
|
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
|
|
return [
|
|
{
|
|
"trade_date": row["trade_date"],
|
|
"sentiment_score": row["score"],
|
|
"seal_rate": row["seal_rate"],
|
|
"limit_up_count": row["limit_up_count"],
|
|
"limit_down_count": row["limit_down_count"],
|
|
"broken_count": row["broken_count"],
|
|
"up_count": row["up_count"],
|
|
"down_count": row["down_count"],
|
|
"amount_billion": row["amount_billion"],
|
|
}
|
|
for row in series[-limit:]
|
|
]
|
|
|
|
def save_screener_strategy(
|
|
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
|
|
builtin: bool = False, strategy_id: int | None = None,
|
|
) -> int:
|
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
regimes_json = json.dumps(regimes, ensure_ascii=False)
|
|
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
|
|
with self.connect() as connection:
|
|
if strategy_id:
|
|
if builtin:
|
|
cursor = connection.execute(
|
|
"""
|
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
|
builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
|
|
""",
|
|
(name, description, regimes_json, formula_json, now, strategy_id),
|
|
)
|
|
else:
|
|
cursor = connection.execute(
|
|
"""
|
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
|
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
|
|
""",
|
|
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
raise ValueError("选股策略不存在。")
|
|
return strategy_id
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO screener_strategies
|
|
(user_id, name, description, regimes, formula, builtin, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
if user_id is None:
|
|
rows = connection.execute(
|
|
"SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
|
|
).fetchall()
|
|
else:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT * FROM screener_strategies
|
|
WHERE builtin = 1 OR user_id = ?
|
|
ORDER BY builtin DESC, updated_at DESC, id
|
|
""",
|
|
(int(user_id),),
|
|
).fetchall()
|
|
result = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["regimes"] = json.loads(item["regimes"])
|
|
item["formula"] = json.loads(item["formula"])
|
|
item["builtin"] = bool(item["builtin"])
|
|
result.append(item)
|
|
return result
|
|
|
|
def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
|
|
(strategy_id,),
|
|
).fetchone()
|
|
if not row:
|
|
raise ValueError("选股策略不存在。")
|
|
if bool(row["builtin"]):
|
|
raise ValueError("内置策略不能删除。")
|
|
if int(row["user_id"] or 0) != int(user_id):
|
|
raise ValueError("无权删除其他账号的策略。")
|
|
cursor = connection.execute(
|
|
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
|
|
(strategy_id, int(user_id)),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def save_screener_run(
|
|
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
|
|
formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
|
|
) -> int:
|
|
normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO screener_runs
|
|
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
|
|
normalized_mode, strategy_name,
|
|
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
|
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
@staticmethod
|
|
def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
|
|
try:
|
|
result = json.loads(row["result"])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
result.setdefault("meta", {}).update(
|
|
{
|
|
"run_id": int(row["id"]),
|
|
"trade_date": str(row["trade_date"] or ""),
|
|
"regime": str(row["regime"] or ""),
|
|
"mode": str(row["mode"] or "smart"),
|
|
"strategy_name": str(row["strategy_name"] or ""),
|
|
"created_at": row["created_at"],
|
|
}
|
|
)
|
|
return result
|
|
|
|
def latest_screener_run(
|
|
self, user_id: int, trade_date: str, mode: str = "",
|
|
) -> dict[str, Any] | None:
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
parameters += (trade_date,)
|
|
mode_clause = ""
|
|
if mode in {"smart", "curated", "quant"}:
|
|
mode_clause = " AND mode = ?"
|
|
parameters += (mode,)
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM screener_runs
|
|
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
|
|
ORDER BY id DESC LIMIT 1
|
|
""",
|
|
parameters,
|
|
).fetchone()
|
|
return self._screener_run_payload(row) if row else None
|
|
|
|
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
parameters += (trade_date,)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
|
runs.strategy_name, runs.result, runs.created_at
|
|
FROM screener_runs runs
|
|
INNER JOIN (
|
|
SELECT mode, MAX(id) AS id
|
|
FROM screener_runs
|
|
WHERE {owner_clause} AND trade_date <= ?
|
|
GROUP BY mode
|
|
) latest ON latest.id = runs.id
|
|
""",
|
|
parameters,
|
|
).fetchall()
|
|
results: dict[str, dict[str, Any]] = {}
|
|
for row in rows:
|
|
mode = str(row["mode"] or "smart")
|
|
payload = self._screener_run_payload(row)
|
|
if mode in {"smart", "curated", "quant"} and payload:
|
|
results[mode] = payload
|
|
return results
|
|
|
|
def latest_screener_context_runs(
|
|
self, user_id: int, trade_date: str, limit: int = 60,
|
|
) -> list[dict[str, Any]]:
|
|
safe_limit = max(1, min(120, int(limit)))
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
parameters += (trade_date, safe_limit)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
WITH ranked AS (
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY
|
|
mode,
|
|
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
|
|
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
|
|
ORDER BY id DESC
|
|
) AS context_rank
|
|
FROM screener_runs
|
|
WHERE {owner_clause} AND trade_date <= ?
|
|
)
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM ranked
|
|
WHERE context_rank = 1
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
parameters,
|
|
).fetchall()
|
|
return [
|
|
payload
|
|
for row in rows
|
|
if (payload := self._screener_run_payload(row)) is not None
|
|
]
|
|
|
|
def screener_runs_for_date(
|
|
self, user_id: int, trade_date: str, limit: int = 80,
|
|
) -> list[dict[str, Any]]:
|
|
safe_limit = max(1, min(160, int(limit)))
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
parameters += (trade_date, safe_limit)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM screener_runs
|
|
WHERE {owner_clause} AND trade_date = ?
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
parameters,
|
|
).fetchall()
|
|
result = []
|
|
seen: set[tuple[str, str, str]] = set()
|
|
for row in rows:
|
|
key = (
|
|
str(row["mode"] or "smart"),
|
|
str(row["regime"] or ""),
|
|
str(row["strategy_name"] or ""),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
payload = self._screener_run_payload(row)
|
|
if payload is not None:
|
|
result.append(payload)
|
|
return result
|
|
|
|
def screener_runs_for_dates(
|
|
self, user_id: int, trade_dates: list[str], limit: int = 1200,
|
|
) -> list[dict[str, Any]]:
|
|
normalized_dates = list(dict.fromkeys(str(item) for item in trade_dates if item))
|
|
if not normalized_dates:
|
|
return []
|
|
safe_limit = max(1, min(2400, int(limit)))
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: list[Any] = [] if int(user_id) == 0 else [int(user_id)]
|
|
placeholders = ",".join("?" for _ in normalized_dates)
|
|
parameters.extend(normalized_dates)
|
|
parameters.append(safe_limit)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
WITH ranked AS (
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY trade_date, mode, regime, strategy_name
|
|
ORDER BY id DESC
|
|
) AS context_rank
|
|
FROM screener_runs
|
|
WHERE {owner_clause} AND trade_date IN ({placeholders})
|
|
)
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM ranked
|
|
WHERE context_rank = 1
|
|
ORDER BY trade_date DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
parameters,
|
|
).fetchall()
|
|
return [
|
|
payload
|
|
for row in rows
|
|
if (payload := self._screener_run_payload(row)) is not None
|
|
]
|
|
|
|
def recent_screener_runs(
|
|
self, user_id: int, trade_date: str, mode: str, limit: int = 40,
|
|
) -> list[dict[str, Any]]:
|
|
if int(user_id) == 0 or mode not in {"smart", "curated", "quant"}:
|
|
return []
|
|
safe_limit = max(1, min(160, int(limit)))
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
WITH ranked AS (
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY trade_date, mode, regime, strategy_name
|
|
ORDER BY id DESC
|
|
) AS context_rank
|
|
FROM screener_runs
|
|
WHERE user_id = ? AND trade_date <= ? AND mode = ?
|
|
)
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM ranked
|
|
WHERE context_rank = 1
|
|
ORDER BY trade_date DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
(int(user_id), trade_date, mode, safe_limit),
|
|
).fetchall()
|
|
return [
|
|
payload
|
|
for row in rows
|
|
if (payload := self._screener_run_payload(row)) is not None
|
|
]
|
|
|
|
def list_screener_batch_markers(
|
|
self, end_date: str, limit: int = 30,
|
|
) -> list[dict[str, Any]]:
|
|
safe_limit = max(1, min(120, int(limit)))
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT cache_key, payload, updated_at
|
|
FROM data_snapshots
|
|
WHERE kind = 'screener_auto_v1' AND cache_key <= ?
|
|
ORDER BY cache_key DESC
|
|
LIMIT ?
|
|
""",
|
|
(end_date, safe_limit),
|
|
).fetchall()
|
|
result = []
|
|
for row in rows:
|
|
try:
|
|
payload = json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
payload.setdefault("trade_date", str(row["cache_key"] or ""))
|
|
payload.setdefault("updated_at", str(row["updated_at"] or ""))
|
|
result.append(payload)
|
|
return result
|
|
|
|
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
parameters: tuple[Any, ...] = (int(run_id),)
|
|
if int(user_id) != 0:
|
|
parameters += (int(user_id),)
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
FROM screener_runs WHERE id = ? AND {owner_clause}
|
|
""",
|
|
parameters,
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
result = self._screener_run_payload(row)
|
|
if result is None:
|
|
return None
|
|
result.setdefault("meta", {}).update(
|
|
{
|
|
"run_id": int(row["id"]),
|
|
"trade_date": row["trade_date"],
|
|
"mode": str(row["mode"] or "smart"),
|
|
"created_at": row["created_at"],
|
|
}
|
|
)
|
|
result["strategy_name"] = row["strategy_name"]
|
|
result["regime"] = row["regime"]
|
|
return result
|
|
|
|
def save_strategy_tracks(
|
|
self,
|
|
user_id: int,
|
|
run_id: int,
|
|
selection_date: str,
|
|
strategy_name: str,
|
|
candidates: list[dict[str, Any]],
|
|
) -> int:
|
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
values = []
|
|
for item in candidates:
|
|
ts_code = str(item.get("ts_code") or "").strip()
|
|
code = str(item.get("code") or ts_code.split(".")[0]).strip()
|
|
entry_price = float(item.get("price") or 0)
|
|
if not ts_code or not code or entry_price <= 0:
|
|
continue
|
|
values.append(
|
|
(
|
|
int(user_id), int(run_id), selection_date, strategy_name, ts_code, code,
|
|
str(item.get("name") or "--"), str(item.get("sector") or "其他"),
|
|
entry_price, now, now,
|
|
)
|
|
)
|
|
with self.connect() as connection:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO strategy_tracks
|
|
(user_id, run_id, selection_date, strategy_name, ts_code, code,
|
|
name, sector, entry_price, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET
|
|
name=excluded.name, sector=excluded.sector,
|
|
entry_price=excluded.entry_price, updated_at=excluded.updated_at
|
|
""",
|
|
values,
|
|
)
|
|
return len(values)
|
|
|
|
def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]:
|
|
limit_batches = max(1, min(50, int(limit_batches)))
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT * FROM strategy_tracks
|
|
WHERE user_id = ? AND run_id IN (
|
|
SELECT run_id FROM strategy_tracks WHERE user_id = ?
|
|
GROUP BY run_id ORDER BY run_id DESC LIMIT ?
|
|
)
|
|
ORDER BY run_id DESC, id
|
|
""",
|
|
(int(user_id), int(user_id), limit_batches),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
|
|
(int(track_id), int(user_id)),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def load_tracking_bars(
|
|
self, targets: list[tuple[str, str]], limit: int = 5
|
|
) -> dict[tuple[str, str], list[dict[str, Any]]]:
|
|
unique_targets = set(targets)
|
|
if not unique_targets:
|
|
return {}
|
|
codes = sorted({ts_code for ts_code, _ in unique_targets})
|
|
earliest_date = min(selection_date for _, selection_date in unique_targets)
|
|
placeholders = ",".join("?" for _ in codes)
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT ts_code, trade_date, open, high, low, close FROM daily_bars
|
|
WHERE ts_code IN ({placeholders}) AND trade_date > ?
|
|
ORDER BY ts_code, trade_date
|
|
""",
|
|
[*codes, earliest_date],
|
|
).fetchall()
|
|
by_code: dict[str, list[dict[str, Any]]] = {}
|
|
for row in rows:
|
|
item = dict(row)
|
|
by_code.setdefault(str(item["ts_code"]), []).append(item)
|
|
row_limit = max(1, min(20, int(limit)))
|
|
return {
|
|
(ts_code, selection_date): [
|
|
row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date
|
|
][:row_limit]
|
|
for ts_code, selection_date in unique_targets
|
|
}
|