stock-up 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stock_up/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
stock_up/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from stock_up.codes import format_code
12
+ from stock_up.config import load_config, write_default_config
13
+ from stock_up.db import init_db
14
+ from stock_up.market.factory import make_provider
15
+ from stock_up.models import Holding, WatchItem
16
+ from stock_up.services.daily import run_daily
17
+ from stock_up.services.dragon_tiger_scanner import run_dragon_tiger_scan
18
+ from stock_up.services.scanner import run_limit_up_scan
19
+ from stock_up.services.tick import run_tick
20
+ from stock_up.repositories import AlertRepository, HoldingRepository, TradeRepository, WatchRepository
21
+ from stock_up.strategy.holding import evaluate_holding
22
+ from stock_up.strategy.trading_day import trading_days_since
23
+ from stock_up.strategy.watch import evaluate_watch
24
+
25
+ app = typer.Typer(help="stock-up CLI")
26
+ watch_app = typer.Typer(help="观察池")
27
+ hold_app = typer.Typer(help="持仓")
28
+ scan_app = typer.Typer(help="扫描")
29
+ app.add_typer(watch_app, name="watch")
30
+ app.add_typer(hold_app, name="hold")
31
+ app.add_typer(scan_app, name="scan")
32
+ console = Console()
33
+
34
+
35
+ def default_home() -> Path:
36
+ return Path.home() / ".stock-up"
37
+
38
+
39
+ def db_path(home: Path) -> Path:
40
+ return home / "data.db"
41
+
42
+
43
+ @app.command()
44
+ def init(home: Path = typer.Option(default_home(), "--home", help="stock-up home directory")):
45
+ """初始化配置和数据库。"""
46
+ home.mkdir(parents=True, exist_ok=True)
47
+ cfg_path = home / "config.yaml"
48
+ if not cfg_path.exists():
49
+ write_default_config(cfg_path)
50
+ (home / "reports").mkdir(exist_ok=True)
51
+ init_db(db_path(home))
52
+ console.print(f"初始化完成: {home}")
53
+
54
+
55
+ def _make_provider(provider: str, purpose: str = "realtime"):
56
+ return make_provider(provider, purpose=purpose)
57
+
58
+
59
+ @app.command()
60
+ def quote(
61
+ code: str,
62
+ provider: str = typer.Option("qq", "--provider", help="qq / akshare / mock"),
63
+ ):
64
+ """查看单只股票行情,便于对比数据源。"""
65
+ full_code = format_code(code) or code
66
+ quotes = _make_provider(provider, purpose="realtime").get_realtime_quotes([full_code])
67
+ if not quotes:
68
+ console.print(f"暂无行情: {full_code}")
69
+ return
70
+ q = quotes[0]
71
+ table = Table("字段", "值")
72
+ table.add_row("代码", q.code)
73
+ table.add_row("名称", q.name)
74
+ table.add_row("当前价", f"{q.now:g}")
75
+ table.add_row("昨收", f"{q.pre_close:g}")
76
+ table.add_row("日高", f"{q.high:g}")
77
+ table.add_row("日低", f"{q.low:g}")
78
+ table.add_row("均价", f"{q.avg:g}")
79
+ table.add_row("成交额", f"{q.amount:g}")
80
+ table.add_row("成交量", f"{q.volume:g}")
81
+ console.print(table)
82
+
83
+
84
+ @app.command()
85
+ def tick(
86
+ home: Path = typer.Option(default_home(), "--home"),
87
+ provider: str = typer.Option("qq", "--provider", help="qq / mock"),
88
+ ):
89
+ """执行一次盘中检查,由外部定时任务调用。"""
90
+ summary = run_tick(db_path(home), _make_provider(provider))
91
+ console.print(f"tick完成: 观察 {summary.updated_watch_count},持仓 {summary.updated_holding_count}")
92
+
93
+
94
+ @app.command()
95
+ def daily(
96
+ home: Path = typer.Option(default_home(), "--home"),
97
+ provider: str = typer.Option("akshare", "--provider", help="akshare / mock"),
98
+ trade_date: str = typer.Option("", "--date"),
99
+ ):
100
+ """执行每日扫描、检查并生成报告。"""
101
+ date_text = trade_date or date.today().isoformat()
102
+ cfg = load_config(home / "config.yaml")
103
+ summary = run_daily(
104
+ db_path(home),
105
+ _make_provider(provider, purpose="daily"),
106
+ date_text,
107
+ home / "reports",
108
+ rsi_max_updates=cfg.technical.rsi.max_updates_per_daily,
109
+ enable_hot_leader_scan=cfg.auto_watch.hot_leader_scan_enabled,
110
+ )
111
+ console.print(f"daily完成: 新增观察 {summary.new_watch_count},观察动作 {summary.watch_action_count},持仓动作 {summary.holding_action_count}")
112
+ console.print(f"日报: {summary.report_path}")
113
+
114
+
115
+ @scan_app.command("dragon-tiger")
116
+ def scan_dragon_tiger(
117
+ home: Path = typer.Option(default_home(), "--home"),
118
+ provider: str = typer.Option("stockapi", "--provider", help="stockapi / mock"),
119
+ trade_date: str = typer.Option("", "--date"),
120
+ ):
121
+ """扫描龙虎榜并加入观察池。"""
122
+ date_text = trade_date or date.today().isoformat()
123
+ summary = run_dragon_tiger_scan(db_path(home), _make_provider(provider, purpose="scan"), date_text)
124
+ console.print(f"龙虎榜扫描完成: 总数 {summary.total_count},加入 {summary.added_count}")
125
+
126
+
127
+ @scan_app.command("limit-up")
128
+ def scan_limit_up(
129
+ home: Path = typer.Option(default_home(), "--home"),
130
+ provider: str = typer.Option("akshare", "--provider", help="akshare / mock"),
131
+ trade_date: str = typer.Option("", "--date"),
132
+ low_mode: str = typer.Option("same_day", "--low-mode"),
133
+ ):
134
+ """扫描涨停池并加入观察池。"""
135
+ date_text = trade_date or date.today().isoformat()
136
+ summary = run_limit_up_scan(db_path(home), _make_provider(provider, purpose="scan"), date_text, initial_low_mode=low_mode) # type: ignore[arg-type]
137
+ console.print(f"涨停扫描完成: 总数 {summary.total_count},加入 {summary.added_count},跳过 {summary.skipped_count}")
138
+
139
+
140
+ @watch_app.command("add")
141
+ def watch_add(
142
+ code: str,
143
+ home: Path = typer.Option(default_home(), "--home"),
144
+ name: str = typer.Option("", "--name"),
145
+ reason: str = typer.Option("手动关注", "--reason"),
146
+ high: float = typer.Option(0.0, "--high"),
147
+ low: float = typer.Option(0.0, "--low"),
148
+ now: float = typer.Option(0.0, "--now"),
149
+ ):
150
+ full_code = format_code(code) or code
151
+ repo = WatchRepository(db_path(home))
152
+ repo.upsert(WatchItem(code=full_code, name=name or full_code, reason=reason, high=high, low=low, now=now))
153
+ console.print(f"已加入观察池: {full_code} {name or full_code}")
154
+
155
+
156
+ @watch_app.command("list")
157
+ def watch_list(home: Path = typer.Option(default_home(), "--home")):
158
+ repo = WatchRepository(db_path(home))
159
+ rows = repo.list_active()
160
+ table = Table("代码", "名称", "状态", "高点", "低点", "现价")
161
+ for item in rows:
162
+ table.add_row(item.code, item.name, item.status, f"{item.high:g}", f"{item.low:g}", f"{item.now:g}")
163
+ console.print(table)
164
+
165
+
166
+ @watch_app.command("abandoned")
167
+ def watch_abandoned(home: Path = typer.Option(default_home(), "--home")):
168
+ repo = WatchRepository(db_path(home))
169
+ rows = repo.list_abandoned()
170
+ table = Table("代码", "名称", "状态")
171
+ for item in rows:
172
+ table.add_row(item.code, item.name, item.status)
173
+ console.print(table)
174
+
175
+
176
+ @watch_app.command("check")
177
+ def watch_check(home: Path = typer.Option(default_home(), "--home")):
178
+ repo = WatchRepository(db_path(home))
179
+ alerts = AlertRepository(db_path(home))
180
+ table = Table("代码", "名称", "动作", "理由")
181
+ today = date.today().isoformat()
182
+ for item in repo.list_active():
183
+ result = evaluate_watch(item)
184
+ if result.action in ("watch", "abandon") and alerts.should_alert(item.code, result.title, result.price, 0.01):
185
+ table.add_row(item.code, item.name, result.title, "; ".join(result.reasons))
186
+ alerts.record(item.code, item.name, result.title, result.level, result.price, "; ".join(result.reasons), today)
187
+ if result.action == "abandon":
188
+ repo.mark_abandoned(item.code, "; ".join(result.reasons), today)
189
+ console.print(table)
190
+
191
+
192
+ @watch_app.command("set")
193
+ def watch_set(
194
+ code: str,
195
+ home: Path = typer.Option(default_home(), "--home"),
196
+ high: Optional[float] = typer.Option(None, "--high"),
197
+ low: Optional[float] = typer.Option(None, "--low"),
198
+ ):
199
+ repo = WatchRepository(db_path(home))
200
+ item = repo.get(code)
201
+ if not item:
202
+ raise typer.BadParameter(f"观察池不存在: {code}")
203
+ if high is not None:
204
+ item.high = high
205
+ if low is not None:
206
+ item.low = low
207
+ repo.upsert(item)
208
+ console.print(f"已更新观察池: {code}")
209
+
210
+
211
+ @hold_app.command("add")
212
+ def hold_add(
213
+ code: str,
214
+ home: Path = typer.Option(default_home(), "--home"),
215
+ name: str = typer.Option("", "--name"),
216
+ cost: float = typer.Option(..., "--cost"),
217
+ qty: int = typer.Option(..., "--qty"),
218
+ buy_date: str = typer.Option("", "--date"),
219
+ high: float = typer.Option(0.0, "--high"),
220
+ low: float = typer.Option(0.0, "--low"),
221
+ swing_low: float = typer.Option(0.0, "--swing-low"),
222
+ ref_high: float = typer.Option(0.0, "--ref-high"),
223
+ rule: str = typer.Option("wolf_swing", "--rule"),
224
+ ):
225
+ watch_repo = WatchRepository(db_path(home))
226
+ holding_repo = HoldingRepository(db_path(home))
227
+ trade_repo = TradeRepository(db_path(home))
228
+
229
+ full_code = format_code(code) or code
230
+ watch_item = watch_repo.get(full_code)
231
+ if watch_item:
232
+ high = high or watch_item.high
233
+ low = low or watch_item.low
234
+ name = name or watch_item.name
235
+ watch_repo.delete(full_code)
236
+
237
+ h = Holding(
238
+ code=full_code,
239
+ name=name or code,
240
+ cost=cost,
241
+ quantity=qty,
242
+ buy_date=buy_date or date.today().isoformat(),
243
+ now=cost,
244
+ highest=max(cost, high),
245
+ high=high,
246
+ low=low,
247
+ swing_low=swing_low,
248
+ ref_high=ref_high,
249
+ rule_type=rule, # type: ignore[arg-type]
250
+ )
251
+ holding_repo.upsert(h)
252
+ trade_repo.record(full_code, name or full_code, "buy", cost, qty, h.buy_date)
253
+ console.print(f"已加入持仓: {full_code} {name or full_code}")
254
+
255
+
256
+ @hold_app.command("list")
257
+ def hold_list(home: Path = typer.Option(default_home(), "--home")):
258
+ repo = HoldingRepository(db_path(home))
259
+ table = Table("代码", "名称", "成本", "数量", "规则")
260
+ for h in repo.list_open():
261
+ table.add_row(h.code, h.name, f"{h.cost:g}", str(h.quantity), h.rule_type)
262
+ console.print(table)
263
+
264
+
265
+ @hold_app.command("add-buy")
266
+ def hold_add_buy(
267
+ code: str,
268
+ home: Path = typer.Option(default_home(), "--home"),
269
+ price: float = typer.Option(..., "--price"),
270
+ qty: int = typer.Option(..., "--qty"),
271
+ trade_date: str = typer.Option("", "--date"),
272
+ ):
273
+ full_code = format_code(code) or code
274
+ repo = HoldingRepository(db_path(home))
275
+ trades = TradeRepository(db_path(home))
276
+ h = repo.add_buy(full_code, price, qty)
277
+ trades.record(full_code, h.name, "add_buy", price, qty, trade_date or date.today().isoformat())
278
+ console.print(f"已加仓: {full_code} 新成本 {h.cost:g} 数量 {h.quantity}")
279
+
280
+
281
+ @hold_app.command("close")
282
+ def hold_close(
283
+ code: str,
284
+ home: Path = typer.Option(default_home(), "--home"),
285
+ price: float = typer.Option(..., "--price"),
286
+ reason: str = typer.Option("", "--reason"),
287
+ trade_date: str = typer.Option("", "--date"),
288
+ watch: bool = typer.Option(False, "--watch"),
289
+ ):
290
+ full_code = format_code(code) or code
291
+ holdings = HoldingRepository(db_path(home))
292
+ trades = TradeRepository(db_path(home))
293
+ h = holdings.get(full_code)
294
+ if not h:
295
+ raise typer.BadParameter(f"持仓不存在: {full_code}")
296
+ close_date = trade_date or date.today().isoformat()
297
+ closed = holdings.close(full_code, price, close_date, reason)
298
+ trades.record(full_code, h.name, "close", price, h.quantity, close_date, reason, closed.realized_pnl)
299
+ if watch:
300
+ WatchRepository(db_path(home)).upsert(WatchItem(code=full_code, name=h.name, reason=f"卖出后重新观察: {reason}", high=h.high, low=h.low, now=price))
301
+ console.print(f"已关闭持仓: {full_code} 已实现盈亏 {closed.realized_pnl:g}")
302
+
303
+
304
+ @hold_app.command("check")
305
+ def hold_check(
306
+ home: Path = typer.Option(default_home(), "--home"),
307
+ today: str = typer.Option("", "--today"),
308
+ ):
309
+ repo = HoldingRepository(db_path(home))
310
+ alerts = AlertRepository(db_path(home))
311
+ table = Table("代码", "名称", "动作", "理由")
312
+ today_text = today or date.today().isoformat()
313
+ for h in repo.list_open():
314
+ days = trading_days_since(h.buy_date, today_text) if h.buy_date else None
315
+ result = evaluate_holding(h, trading_days_since_buy=days)
316
+ reasons = "; ".join(result.reasons) if result.reasons else "暂无动作"
317
+ if alerts.should_alert(h.code, result.title, result.price or h.now or h.cost, 0.01):
318
+ table.add_row(h.code, h.name, result.title, reasons)
319
+ alerts.record(h.code, h.name, result.title, result.level, result.price or h.now or h.cost, reasons, today_text)
320
+ console.print(table)
321
+
322
+
323
+ @hold_app.command("set")
324
+ def hold_set(
325
+ code: str,
326
+ home: Path = typer.Option(default_home(), "--home"),
327
+ highest: Optional[float] = typer.Option(None, "--highest"),
328
+ swing_low: Optional[float] = typer.Option(None, "--swing-low"),
329
+ ref_high: Optional[float] = typer.Option(None, "--ref-high"),
330
+ rule: Optional[str] = typer.Option(None, "--rule"),
331
+ ):
332
+ full_code = format_code(code) or code
333
+ repo = HoldingRepository(db_path(home))
334
+ h = repo.get(full_code)
335
+ if not h:
336
+ raise typer.BadParameter(f"持仓不存在: {full_code}")
337
+ if highest is not None:
338
+ h.highest = highest
339
+ if swing_low is not None:
340
+ h.swing_low = swing_low
341
+ if ref_high is not None:
342
+ h.ref_high = ref_high
343
+ if rule is not None:
344
+ h.rule_type = rule # type: ignore[assignment]
345
+ repo.upsert(h)
346
+ console.print(f"已更新持仓: {full_code}")
stock_up/codes.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def format_code(code: str) -> str | None:
5
+ value = str(code or "").strip().lower()
6
+ if not value:
7
+ return None
8
+ if value.startswith(("sh", "sz", "hk", "bj")):
9
+ return value
10
+ if not value.isdigit():
11
+ return value
12
+
13
+ if len(value) <= 4 or (len(value) == 5 and value.startswith("0")):
14
+ return "hk" + value.zfill(5)
15
+
16
+ if len(value) == 6:
17
+ if value.startswith(("4", "8")):
18
+ return "bj" + value
19
+ if value.startswith("6") or value.startswith(("51", "56", "58")):
20
+ return "sh" + value
21
+ if value.startswith(("0", "1", "3")):
22
+ return "sz" + value
23
+
24
+ return value
stock_up/config.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class MarketConfig(BaseModel):
11
+ quote_source: Literal["akshare", "qq"] = "akshare"
12
+ limit_up_source_order: list[str] = Field(default_factory=lambda: ["akshare_em", "akshare_ths"])
13
+ realtime_fallback_order: list[str] = Field(default_factory=lambda: ["akshare", "qq"])
14
+
15
+
16
+ class TickConfig(BaseModel):
17
+ trading_time_only: bool = True
18
+ min_interval_seconds: int = 20
19
+
20
+
21
+ class LimitUpConfig(BaseModel):
22
+ exclude_st: bool = True
23
+ exclude_bj: bool = True
24
+ exclude_new_stock_days: int = 30
25
+ min_amount: float = 500_000_000
26
+ include_first_board: bool = True
27
+ include_multi_board: bool = True
28
+
29
+
30
+ class AutoWatchConfig(BaseModel):
31
+ hot_leader_scan_enabled: bool = False
32
+
33
+
34
+ class WatchConfig(BaseModel):
35
+ initial_low_mode: Literal["same_day", "recent_1d"] = "same_day"
36
+ buy_382_tolerance: float = 0.03
37
+ buy_618_tolerance: float = 0.02
38
+ abandon_below_786: bool = True
39
+ abandon_below_low: bool = True
40
+
41
+
42
+ class RsiConfig(BaseModel):
43
+ enabled: bool = True
44
+ short_period: int = 6
45
+ long_period: int = 12
46
+ min_history_days: int = 30
47
+ max_updates_per_daily: int = 50
48
+ watch_golden_cross: bool = True
49
+ holding_dead_cross: bool = True
50
+
51
+
52
+ class TechnicalConfig(BaseModel):
53
+ rsi: RsiConfig = Field(default_factory=RsiConfig)
54
+
55
+
56
+ class WolfSwingConfig(BaseModel):
57
+ stop_loss_pct: float = 0.07
58
+ take_profit_arm_pct: float = 0.20
59
+ profit_drawdown_pct: float = 0.30
60
+
61
+
62
+ class HaiLongConfig(BaseModel):
63
+ swing_low_break_pct: float = 0.03
64
+ validate_days: int = 13
65
+
66
+
67
+ class HoldingRulesConfig(BaseModel):
68
+ wolf_swing: WolfSwingConfig = Field(default_factory=WolfSwingConfig)
69
+ hai_long: HaiLongConfig = Field(default_factory=HaiLongConfig)
70
+
71
+
72
+ class HoldingConfig(BaseModel):
73
+ default_rule: Literal["wolf_swing", "hai_long", "both"] = "wolf_swing"
74
+ rules: HoldingRulesConfig = Field(default_factory=HoldingRulesConfig)
75
+ allow_loss_add_on_618: bool = True
76
+
77
+
78
+ class AlertConfig(BaseModel):
79
+ repeat_price_change_pct: float = 0.01
80
+
81
+
82
+ class NotifyConfig(BaseModel):
83
+ terminal: bool = True
84
+ markdown_report: bool = True
85
+
86
+
87
+ class ReportConfig(BaseModel):
88
+ only_actionable: bool = True
89
+ dir: str = "~/.stock-up/reports"
90
+
91
+
92
+ class AppConfig(BaseModel):
93
+ market: MarketConfig = Field(default_factory=MarketConfig)
94
+ tick: TickConfig = Field(default_factory=TickConfig)
95
+ limit_up: LimitUpConfig = Field(default_factory=LimitUpConfig)
96
+ auto_watch: AutoWatchConfig = Field(default_factory=AutoWatchConfig)
97
+ watch: WatchConfig = Field(default_factory=WatchConfig)
98
+ technical: TechnicalConfig = Field(default_factory=TechnicalConfig)
99
+ holding: HoldingConfig = Field(default_factory=HoldingConfig)
100
+ alert: AlertConfig = Field(default_factory=AlertConfig)
101
+ notify: NotifyConfig = Field(default_factory=NotifyConfig)
102
+ report: ReportConfig = Field(default_factory=ReportConfig)
103
+
104
+
105
+ def default_config_dict() -> dict:
106
+ return AppConfig().model_dump(mode="json")
107
+
108
+
109
+ def write_default_config(path: Path) -> None:
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ path.write_text(yaml.safe_dump(default_config_dict(), allow_unicode=True, sort_keys=False), encoding="utf-8")
112
+
113
+
114
+ def load_config(path: Path) -> AppConfig:
115
+ if not path.exists():
116
+ return AppConfig()
117
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
118
+ return AppConfig.model_validate(data)
stock_up/db.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+
7
+ SCHEMA = """
8
+ CREATE TABLE IF NOT EXISTS watchlist (
9
+ code TEXT PRIMARY KEY,
10
+ name TEXT,
11
+ reason TEXT,
12
+ added_date TEXT,
13
+ high REAL,
14
+ low REAL,
15
+ avg REAL,
16
+ now REAL,
17
+ f382 REAL,
18
+ f618 REAL,
19
+ f786 REAL,
20
+ status TEXT,
21
+ abandoned_at TEXT,
22
+ abandon_reason TEXT,
23
+ note TEXT,
24
+ updated_at TEXT
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS holdings (
28
+ code TEXT PRIMARY KEY,
29
+ name TEXT,
30
+ cost REAL,
31
+ quantity INTEGER,
32
+ buy_date TEXT,
33
+ now REAL,
34
+ highest REAL,
35
+ high REAL,
36
+ low REAL,
37
+ swing_low REAL,
38
+ ref_high REAL,
39
+ rule_type TEXT,
40
+ status TEXT,
41
+ note TEXT,
42
+ updated_at TEXT
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS holding_history (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ code TEXT,
48
+ name TEXT,
49
+ cost REAL,
50
+ quantity INTEGER,
51
+ buy_date TEXT,
52
+ close_date TEXT,
53
+ close_price REAL,
54
+ realized_pnl REAL,
55
+ reason TEXT,
56
+ created_at TEXT
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS trades (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ code TEXT,
62
+ name TEXT,
63
+ trade_type TEXT,
64
+ price REAL,
65
+ quantity INTEGER,
66
+ trade_date TEXT,
67
+ reason TEXT,
68
+ realized_pnl REAL,
69
+ created_at TEXT
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS alerts (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ code TEXT,
75
+ name TEXT,
76
+ signal_type TEXT,
77
+ level TEXT,
78
+ price REAL,
79
+ message TEXT,
80
+ trade_date TEXT,
81
+ created_at TEXT
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS quotes_daily (
85
+ code TEXT,
86
+ trade_date TEXT,
87
+ open REAL,
88
+ high REAL,
89
+ low REAL,
90
+ close REAL,
91
+ pre_close REAL,
92
+ pct_chg REAL,
93
+ amount REAL,
94
+ volume REAL,
95
+ is_limit_up INTEGER,
96
+ rsi_short REAL,
97
+ rsi_long REAL,
98
+ PRIMARY KEY (code, trade_date)
99
+ );
100
+ """
101
+
102
+
103
+ def connect(db_path: Path) -> sqlite3.Connection:
104
+ db_path.parent.mkdir(parents=True, exist_ok=True)
105
+ conn = sqlite3.connect(db_path)
106
+ conn.row_factory = sqlite3.Row
107
+ return conn
108
+
109
+
110
+ def init_db(db_path: Path) -> None:
111
+ with connect(db_path) as conn:
112
+ conn.executescript(SCHEMA)
113
+ conn.commit()
File without changes
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, timedelta
4
+
5
+ from stock_up.models import DailyBar, LimitUpStock, Quote
6
+
7
+
8
+ def calc_hist_date_range(days: int, today: date | None = None) -> tuple[str, str]:
9
+ today = today or date.today()
10
+ # Calendar-day buffer: enough to cover weekends/holidays for RSI windows.
11
+ start = today - timedelta(days=max(days * 2 + 10, 40))
12
+ return start.strftime("%Y%m%d"), today.strftime("%Y%m%d")
13
+
14
+
15
+ class AkShareProvider:
16
+ """AkShare data provider.
17
+
18
+ AkShare is an optional dependency. Import lazily so the CLI remains usable
19
+ without installing it when users only need Tencent realtime quotes.
20
+ """
21
+
22
+ def __init__(self):
23
+ try:
24
+ import akshare as ak # type: ignore
25
+ except ImportError as exc:
26
+ raise RuntimeError("AkShare 未安装,请执行: pip install 'stock-up[akshare]'") from exc
27
+ self.ak = ak
28
+
29
+ def get_realtime_quotes(self, codes: list[str]) -> list[Quote]:
30
+ # MVP uses Tencent for realtime by default; keep this conservative.
31
+ return []
32
+
33
+ def get_daily_bars(self, code: str, days: int) -> list[DailyBar]:
34
+ symbol = code.replace("sh", "").replace("sz", "").replace("bj", "")
35
+ start_date, end_date = calc_hist_date_range(days)
36
+ try:
37
+ df = self.ak.stock_zh_a_hist(
38
+ symbol=symbol,
39
+ period="daily",
40
+ start_date=start_date,
41
+ end_date=end_date,
42
+ adjust="",
43
+ )
44
+ except Exception:
45
+ return []
46
+ rows = []
47
+ for _, row in df.tail(days).iterrows():
48
+ rows.append(DailyBar(
49
+ code=code,
50
+ trade_date=str(row.get("日期", "")),
51
+ open=float(row.get("开盘", 0) or 0),
52
+ high=float(row.get("最高", 0) or 0),
53
+ low=float(row.get("最低", 0) or 0),
54
+ close=float(row.get("收盘", 0) or 0),
55
+ volume=float(row.get("成交量", 0) or 0),
56
+ amount=float(row.get("成交额", 0) or 0),
57
+ ))
58
+ return rows
59
+
60
+ def get_limit_up_pool(self, trade_date: str) -> list[LimitUpStock]:
61
+ date_text = trade_date.replace("-", "")
62
+ df = self.ak.stock_zt_pool_em(date=date_text)
63
+ items: list[LimitUpStock] = []
64
+ for _, row in df.iterrows():
65
+ code = str(row.get("代码", ""))
66
+ name = str(row.get("名称", ""))
67
+ items.append(LimitUpStock(
68
+ code=code,
69
+ name=name,
70
+ trade_date=trade_date,
71
+ high=float(row.get("最新价", 0) or 0),
72
+ low=float(row.get("最新价", 0) or 0),
73
+ close=float(row.get("最新价", 0) or 0),
74
+ amount=float(row.get("成交额", 0) or 0),
75
+ reason=str(row.get("涨停原因类别", "") or ""),
76
+ board_count=int(row.get("连板数", 1) or 1),
77
+ ))
78
+ return items
79
+
80
+ def get_trade_calendar(self) -> list[str]:
81
+ try:
82
+ df = self.ak.tool_trade_date_hist_sina()
83
+ except Exception:
84
+ return []
85
+ return [str(v) for v in df.get("trade_date", []).tolist()]