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 +1 -0
- stock_up/cli.py +346 -0
- stock_up/codes.py +24 -0
- stock_up/config.py +118 -0
- stock_up/db.py +113 -0
- stock_up/market/__init__.py +0 -0
- stock_up/market/akshare_provider.py +85 -0
- stock_up/market/base.py +28 -0
- stock_up/market/factory.py +25 -0
- stock_up/market/mock.py +37 -0
- stock_up/market/qq.py +138 -0
- stock_up/market/stockapi.py +349 -0
- stock_up/models.py +130 -0
- stock_up/repositories.py +223 -0
- stock_up/services/__init__.py +0 -0
- stock_up/services/daily.py +95 -0
- stock_up/services/dragon_tiger_scanner.py +39 -0
- stock_up/services/hot_leader_scanner.py +49 -0
- stock_up/services/initial_low.py +14 -0
- stock_up/services/reporter.py +39 -0
- stock_up/services/rsi.py +123 -0
- stock_up/services/rsi_budget.py +17 -0
- stock_up/services/scanner.py +69 -0
- stock_up/services/tick.py +66 -0
- stock_up/strategy/fib.py +10 -0
- stock_up/strategy/holding.py +83 -0
- stock_up/strategy/rsi.py +45 -0
- stock_up/strategy/technical.py +18 -0
- stock_up/strategy/trading_day.py +32 -0
- stock_up/strategy/watch.py +42 -0
- stock_up-0.1.0.dist-info/METADATA +264 -0
- stock_up-0.1.0.dist-info/RECORD +34 -0
- stock_up-0.1.0.dist-info/WHEEL +4 -0
- stock_up-0.1.0.dist-info/entry_points.txt +2 -0
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()]
|