pyqqq-script 0.0.1__tar.gz
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.
- pyqqq_script-0.0.1/PKG-INFO +75 -0
- pyqqq_script-0.0.1/README.md +55 -0
- pyqqq_script-0.0.1/pyproject.toml +41 -0
- pyqqq_script-0.0.1/pyqqq3/__init__.py +15 -0
- pyqqq_script-0.0.1/pyqqq3/application/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/application/backtest.py +213 -0
- pyqqq_script-0.0.1/pyqqq3/application/live.py +142 -0
- pyqqq_script-0.0.1/pyqqq3/application/messaging/__init__.py +5 -0
- pyqqq_script-0.0.1/pyqqq3/application/messaging/bus.py +38 -0
- pyqqq_script-0.0.1/pyqqq3/application/session.py +48 -0
- pyqqq_script-0.0.1/pyqqq3/application/store.py +87 -0
- pyqqq_script-0.0.1/pyqqq3/auth.py +28 -0
- pyqqq_script-0.0.1/pyqqq3/infra/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/infra/broker/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/infra/broker/kis.py +134 -0
- pyqqq_script-0.0.1/pyqqq3/infra/broker/mock.py +599 -0
- pyqqq_script-0.0.1/pyqqq3/infra/data/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/infra/data/historical.py +159 -0
- pyqqq_script-0.0.1/pyqqq3/infra/data/symbols.py +49 -0
- pyqqq_script-0.0.1/pyqqq3/infra/presentation/__init__.py +9 -0
- pyqqq_script-0.0.1/pyqqq3/infra/presentation/cli_reporter.py +60 -0
- pyqqq_script-0.0.1/pyqqq3/infra/store/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/infra/store/file.py +168 -0
- pyqqq_script-0.0.1/pyqqq3/infra/store/http.py +403 -0
- pyqqq_script-0.0.1/pyqqq3/infra/store/mongo.py +361 -0
- pyqqq_script-0.0.1/pyqqq3/model/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/model/account.py +127 -0
- pyqqq_script-0.0.1/pyqqq3/model/execution/__init__.py +24 -0
- pyqqq_script-0.0.1/pyqqq3/model/execution/events.py +64 -0
- pyqqq_script-0.0.1/pyqqq3/model/execution/journal.py +132 -0
- pyqqq_script-0.0.1/pyqqq3/model/execution/order.py +32 -0
- pyqqq_script-0.0.1/pyqqq3/model/result.py +280 -0
- pyqqq_script-0.0.1/pyqqq3/model/symbol.py +39 -0
- pyqqq_script-0.0.1/pyqqq3/model/symbol_repository.py +31 -0
- pyqqq_script-0.0.1/pyqqq3/model/types.py +90 -0
- pyqqq_script-0.0.1/pyqqq3/strategy/__init__.py +11 -0
- pyqqq_script-0.0.1/pyqqq3/strategy/base.py +132 -0
- pyqqq_script-0.0.1/pyqqq3/strategy/series.py +101 -0
- pyqqq_script-0.0.1/pyqqq3/strategy/ta.py +269 -0
- pyqqq_script-0.0.1/pyqqq3/trading/__init__.py +0 -0
- pyqqq_script-0.0.1/pyqqq3/trading/broker.py +47 -0
- pyqqq_script-0.0.1/pyqqq3/trading/engine.py +122 -0
- pyqqq_script-0.0.1/pyqqq3/trading/feed.py +27 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pyqqq-script
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pine Script-style trading library built on top of pyqqq
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: PyQQQ team
|
|
7
|
+
Author-email: pyqqq.cs@gmail.com
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: numpy (>=1.24)
|
|
14
|
+
Requires-Dist: pandas (>=2.0)
|
|
15
|
+
Requires-Dist: pymongo (>=4.6)
|
|
16
|
+
Requires-Dist: pyqqq (>=0.12.233)
|
|
17
|
+
Project-URL: Documentation, https://docs.pyqqq.net
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# PyQQQ Script
|
|
22
|
+
|
|
23
|
+
**"우리는 누구나 자신의 투자 아이디어를 현실로 만들 수 있다고 믿습니다."**
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## 사전 준비
|
|
27
|
+
|
|
28
|
+
먼저 PyQQQ 플랫폼에 회원가입 후 API Key를 발급받아야 합니다. 자세한 절차는 공식 가이드를 https://docs.pyqqq.net/getting_started/install.html 참고하세요.
|
|
29
|
+
|
|
30
|
+
발급받은 API Key는 환경 변수로 설정해 사용합니다.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export PYQQQ_API_KEY="발급받은_API_KEY"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
## 설치
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pyqqq-script
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
아래는 PyQQQ Script의 기본 사용 흐름을 보여주는 간단한 예제입니다. `Strategy`를 상속받아 `next()`에서 매매 로직을 작성하고, `Backtest`로 실행한 뒤 결과를 출력하는 흐름입니다.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from pyqqq3 import Backtest, Strategy, ta
|
|
50
|
+
from pyqqq3.infra.presentation import print_result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Simple(Strategy):
|
|
54
|
+
def next(self):
|
|
55
|
+
sma5 = ta.sma(self.close, 5)
|
|
56
|
+
sma20 = ta.sma(self.close, 20)
|
|
57
|
+
|
|
58
|
+
if sma5 > sma20 and self.position.size == 0:
|
|
59
|
+
self.buy(amount=1_000_000)
|
|
60
|
+
elif sma5 < sma20 and self.position.size > 0:
|
|
61
|
+
self.sell()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
bt = Backtest(strategy=Simple, symbol="005930", start="2026-04-01", end="2026-04-07")
|
|
65
|
+
print_result(bt.run())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> 실제로 동작하는 전략들은 `examples/` 디렉터리를 참고하세요.
|
|
69
|
+
|
|
70
|
+
실행:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python examples/mean_reversion_rsi_bb.py
|
|
74
|
+
```
|
|
75
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
# PyQQQ Script
|
|
3
|
+
|
|
4
|
+
**"우리는 누구나 자신의 투자 아이디어를 현실로 만들 수 있다고 믿습니다."**
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## 사전 준비
|
|
8
|
+
|
|
9
|
+
먼저 PyQQQ 플랫폼에 회원가입 후 API Key를 발급받아야 합니다. 자세한 절차는 공식 가이드를 https://docs.pyqqq.net/getting_started/install.html 참고하세요.
|
|
10
|
+
|
|
11
|
+
발급받은 API Key는 환경 변수로 설정해 사용합니다.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export PYQQQ_API_KEY="발급받은_API_KEY"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## 설치
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install pyqqq-script
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
아래는 PyQQQ Script의 기본 사용 흐름을 보여주는 간단한 예제입니다. `Strategy`를 상속받아 `next()`에서 매매 로직을 작성하고, `Backtest`로 실행한 뒤 결과를 출력하는 흐름입니다.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pyqqq3 import Backtest, Strategy, ta
|
|
31
|
+
from pyqqq3.infra.presentation import print_result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Simple(Strategy):
|
|
35
|
+
def next(self):
|
|
36
|
+
sma5 = ta.sma(self.close, 5)
|
|
37
|
+
sma20 = ta.sma(self.close, 20)
|
|
38
|
+
|
|
39
|
+
if sma5 > sma20 and self.position.size == 0:
|
|
40
|
+
self.buy(amount=1_000_000)
|
|
41
|
+
elif sma5 < sma20 and self.position.size > 0:
|
|
42
|
+
self.sell()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
bt = Backtest(strategy=Simple, symbol="005930", start="2026-04-01", end="2026-04-07")
|
|
46
|
+
print_result(bt.run())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> 실제로 동작하는 전략들은 `examples/` 디렉터리를 참고하세요.
|
|
50
|
+
|
|
51
|
+
실행:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python examples/mean_reversion_rsi_bb.py
|
|
55
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pyqqq-script"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Pine Script-style trading library built on top of pyqqq"
|
|
5
|
+
authors = ["PyQQQ team <pyqqq.cs@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "pyqqq3"}]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
documentation = "https://docs.pyqqq.net"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = ">=3.11,<4.0"
|
|
13
|
+
pandas = ">=2.0"
|
|
14
|
+
numpy = ">=1.24"
|
|
15
|
+
pymongo = ">=4.6"
|
|
16
|
+
pyqqq = ">=0.12.233"
|
|
17
|
+
|
|
18
|
+
[tool.poetry.group.dev.dependencies]
|
|
19
|
+
pytest = ">=8.0"
|
|
20
|
+
pytest-cov = ">=4.1"
|
|
21
|
+
ta = ">=0.11"
|
|
22
|
+
ruff = ">=0.6"
|
|
23
|
+
mongomock = ">=4.1"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["poetry-core"]
|
|
27
|
+
build-backend = "poetry.core.masonry.api"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
pythonpath = ["."]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "W", "F", "I"]
|
|
38
|
+
ignore = ["E203"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint.per-file-ignores]
|
|
41
|
+
"tests/*" = ["F401"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pyqqq3 — Pine Script 스타일 트레이딩 라이브러리."""
|
|
2
|
+
|
|
3
|
+
from pyqqq3.application.backtest import Backtest
|
|
4
|
+
from pyqqq3.application.live import LiveTrading
|
|
5
|
+
from pyqqq3.auth import get_api_key, set_api_key
|
|
6
|
+
from pyqqq3.strategy import Strategy, ta
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Strategy",
|
|
10
|
+
"Backtest",
|
|
11
|
+
"LiveTrading",
|
|
12
|
+
"ta",
|
|
13
|
+
"set_api_key",
|
|
14
|
+
"get_api_key",
|
|
15
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Backtest — 과거 데이터 시뮬레이션 파사드.
|
|
2
|
+
|
|
3
|
+
HistoricalDataFeed + MockBroker + TradingEngine 을 조립한다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from pyqqq3.application.messaging import EventBus
|
|
15
|
+
from pyqqq3.application.session import Session, new_session_id
|
|
16
|
+
from pyqqq3.auth import ensure_api_key
|
|
17
|
+
from pyqqq3.infra.broker.mock import MockBroker
|
|
18
|
+
from pyqqq3.infra.data.historical import HistoricalDataFeed
|
|
19
|
+
from pyqqq3.model.account import Account
|
|
20
|
+
from pyqqq3.model.execution.journal import ExecutionJournal
|
|
21
|
+
from pyqqq3.model.result import Result
|
|
22
|
+
from pyqqq3.model.symbol import Symbol, SymbolCode
|
|
23
|
+
from pyqqq3.trading.engine import TradingEngine
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pyqqq3.application.store import SessionStore
|
|
27
|
+
from pyqqq3.model.symbol_repository import SymbolRepository
|
|
28
|
+
from pyqqq3.strategy import Strategy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Backtest:
|
|
32
|
+
"""과거 OHLCV DataFrame 으로 전략을 시뮬레이션한다.
|
|
33
|
+
|
|
34
|
+
두 가지 모드:
|
|
35
|
+
1. DataFrame 직접 제공 (테스트용):
|
|
36
|
+
bt = Backtest(strategy=RSI_BB, df=df, capital=10_000_000)
|
|
37
|
+
|
|
38
|
+
2. Symbol/Date 범위 (프로덕션용):
|
|
39
|
+
bt = Backtest(strategy=RSI_BB, symbol="005930",
|
|
40
|
+
start="2025-01-01", end="2025-12-31", capital=10_000_000)
|
|
41
|
+
|
|
42
|
+
``symbol`` 이 주어지면 ``symbol_repo`` 를 통해 즉시 메타데이터를 하이드레이트한다.
|
|
43
|
+
종목이 존재하지 않으면 ``SymbolNotFound`` 가 발생한다.
|
|
44
|
+
|
|
45
|
+
session_store 를 지정하면 운용 이력을 기록한다:
|
|
46
|
+
from pyqqq3.infra.store.file import FileStore
|
|
47
|
+
bt = Backtest(..., session_store=FileStore())
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
strategy: type["Strategy"],
|
|
53
|
+
df: pd.DataFrame | None = None,
|
|
54
|
+
symbol: "str | Symbol | None" = None,
|
|
55
|
+
start: str | dt.date | None = None,
|
|
56
|
+
end: str | dt.date | None = None,
|
|
57
|
+
capital: int = 10_000_000,
|
|
58
|
+
commission: float = 0.00015,
|
|
59
|
+
slippage: float = 0.0,
|
|
60
|
+
session_store: "SessionStore | None" = None,
|
|
61
|
+
symbol_repo: "SymbolRepository | None" = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if df is None and (symbol is None or start is None or end is None):
|
|
64
|
+
raise ValueError("Either df or (symbol, start, end) must be provided")
|
|
65
|
+
|
|
66
|
+
self.strategy = strategy
|
|
67
|
+
self.df = df
|
|
68
|
+
self.start = start
|
|
69
|
+
self.end = end
|
|
70
|
+
self.capital = capital
|
|
71
|
+
self.commission = commission
|
|
72
|
+
self.slippage = slippage
|
|
73
|
+
self.session_store = session_store
|
|
74
|
+
|
|
75
|
+
self._symbol: Symbol | None = None
|
|
76
|
+
if isinstance(symbol, Symbol):
|
|
77
|
+
self._symbol = symbol
|
|
78
|
+
elif symbol:
|
|
79
|
+
if symbol_repo is None:
|
|
80
|
+
from pyqqq3.infra.data.symbols import DomesticRepository
|
|
81
|
+
|
|
82
|
+
symbol_repo = DomesticRepository()
|
|
83
|
+
ensure_api_key()
|
|
84
|
+
self._symbol = symbol_repo.require(SymbolCode(symbol))
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def symbol(self) -> "Symbol | None":
|
|
88
|
+
return self._symbol
|
|
89
|
+
|
|
90
|
+
def run(self) -> "Result":
|
|
91
|
+
if self.df is not None:
|
|
92
|
+
data_feed = HistoricalDataFeed(df=self.df)
|
|
93
|
+
else:
|
|
94
|
+
assert self._symbol is not None # __init__ 에서 검증됨
|
|
95
|
+
ensure_api_key()
|
|
96
|
+
data_feed = HistoricalDataFeed.from_symbol(
|
|
97
|
+
symbol=str(self._symbol.code), start=self.start, end=self.end
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
journal = ExecutionJournal()
|
|
101
|
+
account = Account()
|
|
102
|
+
bus = EventBus()
|
|
103
|
+
bus.subscribe(journal.append)
|
|
104
|
+
bus.subscribe(account.apply)
|
|
105
|
+
session_id = new_session_id()
|
|
106
|
+
|
|
107
|
+
broker = MockBroker(
|
|
108
|
+
capital=int(self.capital),
|
|
109
|
+
account=account,
|
|
110
|
+
commission=float(self.commission),
|
|
111
|
+
slippage=float(self.slippage),
|
|
112
|
+
symbol=self._symbol,
|
|
113
|
+
event_sink=bus.publish,
|
|
114
|
+
)
|
|
115
|
+
engine = TradingEngine(
|
|
116
|
+
strategy_class=self.strategy,
|
|
117
|
+
data_feed=data_feed,
|
|
118
|
+
broker=broker,
|
|
119
|
+
account=account,
|
|
120
|
+
journal=journal,
|
|
121
|
+
)
|
|
122
|
+
strategy_instance = engine.prepare()
|
|
123
|
+
strategy_params = _extract_strategy_params(strategy_instance)
|
|
124
|
+
|
|
125
|
+
equity_curve = engine.run()
|
|
126
|
+
self._account = account # 테스트/디버깅용 노출 (dual-write 검증)
|
|
127
|
+
|
|
128
|
+
session = Session(
|
|
129
|
+
session_id=session_id,
|
|
130
|
+
kind="backtest",
|
|
131
|
+
created_at=dt.datetime.now(),
|
|
132
|
+
strategy_name=self.strategy.__name__,
|
|
133
|
+
strategy_params=strategy_params,
|
|
134
|
+
symbols=(self._symbol,) if self._symbol is not None else (),
|
|
135
|
+
start_date=_to_date(self.start),
|
|
136
|
+
end_date=_to_date(self.end),
|
|
137
|
+
engine_config={
|
|
138
|
+
"capital": int(self.capital),
|
|
139
|
+
"commission": float(self.commission),
|
|
140
|
+
"slippage": float(self.slippage),
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
trades = journal.trades(symbol=self._symbol) if self._symbol is not None else []
|
|
145
|
+
result = Result.from_equity_curve(
|
|
146
|
+
trades=trades,
|
|
147
|
+
equity_curve=equity_curve,
|
|
148
|
+
symbol=self._symbol,
|
|
149
|
+
session_id=session_id,
|
|
150
|
+
initial_capital=float(self.capital),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if self.session_store is not None:
|
|
154
|
+
sid = self.session_store.start(session)
|
|
155
|
+
self.session_store.append_events(sid, journal.events)
|
|
156
|
+
self.session_store.finalize(sid, stats=result.stats())
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_FRAMEWORK_ATTRS = frozenset(
|
|
162
|
+
{
|
|
163
|
+
"open",
|
|
164
|
+
"high",
|
|
165
|
+
"low",
|
|
166
|
+
"close",
|
|
167
|
+
"volume",
|
|
168
|
+
"bar_index",
|
|
169
|
+
"time",
|
|
170
|
+
"is_last_bar",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_strategy_params(strategy: "Strategy") -> dict:
|
|
176
|
+
"""전략 인스턴스에서 직렬화 가능한 파라미터를 추출한다.
|
|
177
|
+
|
|
178
|
+
클래스 레벨 변수와 ``init()`` 안에서 ``self.X = ...`` 로 선언한 인스턴스 변수를
|
|
179
|
+
모두 포함한다. 같은 이름이 양쪽에 있으면 인스턴스 값이 우선한다 (Python 속성
|
|
180
|
+
lookup 과 동일). 엔진이 주입하는 framework 속성(open/high/low/close/volume,
|
|
181
|
+
bar_index, time, is_last_bar)과 underscore-prefixed 내부 속성은 제외한다.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
params: dict = {}
|
|
185
|
+
|
|
186
|
+
for name, value in inspect.getmembers(type(strategy)):
|
|
187
|
+
if name.startswith("_") or name in _FRAMEWORK_ATTRS:
|
|
188
|
+
continue
|
|
189
|
+
if callable(value) or inspect.isclass(value):
|
|
190
|
+
continue
|
|
191
|
+
if isinstance(value, (int, float, str, bool)):
|
|
192
|
+
params[name] = value
|
|
193
|
+
|
|
194
|
+
for name, value in vars(strategy).items():
|
|
195
|
+
if name.startswith("_") or name in _FRAMEWORK_ATTRS:
|
|
196
|
+
continue
|
|
197
|
+
if isinstance(value, (int, float, str, bool)):
|
|
198
|
+
params[name] = value
|
|
199
|
+
|
|
200
|
+
return params
|
|
201
|
+
except Exception:
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _to_date(value: "str | dt.date | None") -> "dt.date | None":
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
if isinstance(value, dt.date):
|
|
209
|
+
return value
|
|
210
|
+
try:
|
|
211
|
+
return dt.date.fromisoformat(str(value)[:10])
|
|
212
|
+
except Exception:
|
|
213
|
+
return None
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""LiveTrading — 실시간 트레이딩 파사드.
|
|
2
|
+
|
|
3
|
+
Backtest 와 동일한 조립 구조를 쓴다:
|
|
4
|
+
DataFeed + Broker + EventBus + Account + ExecutionJournal → TradingEngine
|
|
5
|
+
|
|
6
|
+
차이는 Broker/DataFeed 의 구현체뿐이다 (KISDomesticBroker + RealtimeDataFeed).
|
|
7
|
+
이 파일은 DI 배선과 lifecycle 만 담당한다.
|
|
8
|
+
|
|
9
|
+
session_store 가 주입되면 EventBus 에 streaming sink 를 붙여 이벤트가
|
|
10
|
+
발생하는 즉시 store 로 흘려보낸다 — 프로세스가 중간에 죽어도 그 시점까지의
|
|
11
|
+
체결 이력이 보존된다.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from pyqqq3.application.backtest import _extract_strategy_params
|
|
20
|
+
from pyqqq3.application.messaging import EventBus
|
|
21
|
+
from pyqqq3.application.session import Session, new_session_id
|
|
22
|
+
from pyqqq3.auth import ensure_api_key
|
|
23
|
+
from pyqqq3.model.account import Account
|
|
24
|
+
from pyqqq3.model.execution.journal import ExecutionJournal
|
|
25
|
+
from pyqqq3.model.result import Result
|
|
26
|
+
from pyqqq3.model.symbol import Symbol, SymbolCode
|
|
27
|
+
from pyqqq3.trading.engine import TradingEngine
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pyqqq3.application.store import SessionStore
|
|
31
|
+
from pyqqq3.model.symbol_repository import SymbolRepository
|
|
32
|
+
from pyqqq3.strategy import Strategy
|
|
33
|
+
from pyqqq3.trading.broker import Broker
|
|
34
|
+
from pyqqq3.trading.feed import DataFeed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LiveTrading:
|
|
38
|
+
"""실시간 트레이딩 파사드.
|
|
39
|
+
|
|
40
|
+
브로커와 데이터 피드는 호출자가 직접 주입한다. 브로커는 외부 거래소로부터
|
|
41
|
+
수신한 체결/거부 이벤트를 ``event_sink`` 로 발행해야 한다 (Broker 규약).
|
|
42
|
+
LiveTrading 은 EventBus 를 통해 이를 Journal + Account 로 fan-out 한다.
|
|
43
|
+
|
|
44
|
+
종목은 진입 시점에 ``symbol_repo.require()`` 로 즉시 검증·하이드레이트한다.
|
|
45
|
+
존재하지 않는 종목 코드는 ``SymbolNotFound`` 로 거부된다.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
strategy: Strategy 서브클래스 타입.
|
|
49
|
+
data_feed: 실시간 DataFeed 구현체.
|
|
50
|
+
broker_factory: ``event_sink`` 를 받아 Broker 를 반환하는 팩토리.
|
|
51
|
+
symbol: 거래 종목 코드 (raw str). 즉시 ``Symbol`` 로 하이드레이트된다.
|
|
52
|
+
symbol_repo: 종목 메타데이터 조회용 Repository. 미주입 시
|
|
53
|
+
``DomesticRepository`` 가 사용된다.
|
|
54
|
+
session_store: 운용 이력 저장소 (선택).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
strategy: type["Strategy"],
|
|
60
|
+
data_feed: "DataFeed",
|
|
61
|
+
broker_factory,
|
|
62
|
+
symbol: "str | Symbol",
|
|
63
|
+
symbol_repo: "SymbolRepository | None" = None,
|
|
64
|
+
session_store: "SessionStore | None" = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
if not symbol:
|
|
67
|
+
raise ValueError("LiveTrading 은 symbol 이 필수입니다")
|
|
68
|
+
|
|
69
|
+
self.strategy = strategy
|
|
70
|
+
self.data_feed = data_feed
|
|
71
|
+
self.broker_factory = broker_factory
|
|
72
|
+
self.session_store = session_store
|
|
73
|
+
|
|
74
|
+
if isinstance(symbol, Symbol):
|
|
75
|
+
self._symbol: Symbol = symbol
|
|
76
|
+
else:
|
|
77
|
+
if symbol_repo is None:
|
|
78
|
+
from pyqqq3.infra.data.symbols import DomesticRepository
|
|
79
|
+
|
|
80
|
+
symbol_repo = DomesticRepository()
|
|
81
|
+
ensure_api_key()
|
|
82
|
+
self._symbol = symbol_repo.require(SymbolCode(symbol))
|
|
83
|
+
|
|
84
|
+
self._account: Account | None = None
|
|
85
|
+
self._journal: ExecutionJournal | None = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def symbol(self) -> "Symbol":
|
|
89
|
+
return self._symbol
|
|
90
|
+
|
|
91
|
+
def run(self) -> Result:
|
|
92
|
+
journal = ExecutionJournal()
|
|
93
|
+
account = Account()
|
|
94
|
+
bus = EventBus()
|
|
95
|
+
bus.subscribe(journal.append)
|
|
96
|
+
bus.subscribe(account.apply)
|
|
97
|
+
|
|
98
|
+
session_id = new_session_id()
|
|
99
|
+
# broker 생성 시 CashDeposited 가 emit 되므로 session start 와 sink 구독은
|
|
100
|
+
# broker_factory 호출 *전*에 끝나야 한다.
|
|
101
|
+
session = Session(
|
|
102
|
+
session_id=session_id,
|
|
103
|
+
kind="live",
|
|
104
|
+
created_at=dt.datetime.now(),
|
|
105
|
+
strategy_name=self.strategy.__name__,
|
|
106
|
+
strategy_params=_extract_strategy_params(self.strategy()),
|
|
107
|
+
symbols=(self._symbol,),
|
|
108
|
+
start_date=None,
|
|
109
|
+
end_date=None,
|
|
110
|
+
engine_config={},
|
|
111
|
+
)
|
|
112
|
+
if self.session_store is not None:
|
|
113
|
+
store = self.session_store
|
|
114
|
+
store.start(session)
|
|
115
|
+
bus.subscribe(lambda e: store.append_events(session_id, [e]))
|
|
116
|
+
|
|
117
|
+
broker: "Broker" = self.broker_factory(bus.publish, account)
|
|
118
|
+
|
|
119
|
+
engine = TradingEngine(
|
|
120
|
+
strategy_class=self.strategy,
|
|
121
|
+
data_feed=self.data_feed,
|
|
122
|
+
broker=broker,
|
|
123
|
+
account=account,
|
|
124
|
+
journal=journal,
|
|
125
|
+
)
|
|
126
|
+
engine.prepare()
|
|
127
|
+
equity_curve = engine.run()
|
|
128
|
+
|
|
129
|
+
self._account = account
|
|
130
|
+
self._journal = journal
|
|
131
|
+
|
|
132
|
+
result = Result.from_equity_curve(
|
|
133
|
+
trades=journal.trades(symbol=self._symbol),
|
|
134
|
+
equity_curve=equity_curve,
|
|
135
|
+
symbol=self._symbol,
|
|
136
|
+
session_id=session_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if self.session_store is not None:
|
|
140
|
+
self.session_store.finalize(session_id, stats=result.stats())
|
|
141
|
+
|
|
142
|
+
return result
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""EventBus — 동기 in-process fanout.
|
|
2
|
+
|
|
3
|
+
브로커가 publish 한 ExecutionEvent 를 구독자(journal, Account 등)에게
|
|
4
|
+
순차적으로 전달한다. Phase 1 단계에서는 단일 스레드, 동기 실행을 가정한다.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from pyqqq3.model.execution.events import ExecutionEvent
|
|
12
|
+
|
|
13
|
+
EventHandler = Callable[[ExecutionEvent], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventBus:
|
|
17
|
+
"""동기 이벤트 fanout.
|
|
18
|
+
|
|
19
|
+
한 번에 하나의 이벤트를 등록된 모든 핸들러에게 순차 전달한다.
|
|
20
|
+
구독자 등록 순서대로 실행되며, 한 핸들러 예외가 다음 핸들러 실행을 막는다
|
|
21
|
+
(journal / Account 의 일관성 확보를 위해 의도된 동작).
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
bus = EventBus()
|
|
25
|
+
bus.subscribe(journal.append)
|
|
26
|
+
bus.subscribe(account.apply)
|
|
27
|
+
broker = MockBroker(..., event_sink=bus.publish)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._handlers: list[EventHandler] = []
|
|
32
|
+
|
|
33
|
+
def subscribe(self, handler: EventHandler) -> None:
|
|
34
|
+
self._handlers.append(handler)
|
|
35
|
+
|
|
36
|
+
def publish(self, event: ExecutionEvent) -> None:
|
|
37
|
+
for handler in self._handlers:
|
|
38
|
+
handler(event)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Session — application 레이어의 운용 단위 정의.
|
|
2
|
+
|
|
3
|
+
도메인 라이프사이클 ``Strategy → Session → SessionReport`` 의 가운데 단계로,
|
|
4
|
+
backtest/paper/live 를 아우르는 운용 인스턴스의 식별자·전략·심볼·기간·엔진설정을
|
|
5
|
+
담는 경계 DTO 다. ``SessionStore`` 가 이 타입들을 키로 영속화한다.
|
|
6
|
+
|
|
7
|
+
심볼은 ``Symbol`` 객체로 보유하며, 영속화 어댑터(MongoStore/FileStore)는
|
|
8
|
+
직렬화 시 ``str(symbol.code)`` 로 변환하고 역직렬화 시 ``SymbolRepository`` 로
|
|
9
|
+
하이드레이트한다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pyqqq3.model.symbol import Symbol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Session:
|
|
25
|
+
session_id: str
|
|
26
|
+
kind: str # "backtest" | "paper" | "live"
|
|
27
|
+
created_at: dt.datetime
|
|
28
|
+
strategy_name: str
|
|
29
|
+
strategy_params: dict[str, Any]
|
|
30
|
+
symbols: tuple["Symbol", ...]
|
|
31
|
+
start_date: dt.date | None
|
|
32
|
+
end_date: dt.date | None
|
|
33
|
+
engine_config: dict[str, Any] # commission, slippage, capital
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SessionReport:
|
|
38
|
+
session_id: str
|
|
39
|
+
created_at: dt.datetime
|
|
40
|
+
strategy_name: str
|
|
41
|
+
symbol: "Symbol | None"
|
|
42
|
+
total_return_pct: float
|
|
43
|
+
trade_count: int
|
|
44
|
+
kind: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def new_session_id() -> str:
|
|
48
|
+
return f"{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|