programgarden-community 0.1.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.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.3
2
+ Name: programgarden-community
3
+ Version: 0.1.1
4
+ Summary: 증권 분석에 필요한 외부 전략 모아둔 플러그인
5
+ Author: 프로그램동산
6
+ Author-email: coding@programgarden.com
7
+ Requires-Python: >=3.9
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: croniter (>=6.0.0,<7.0.0)
15
+ Requires-Dist: programgarden-core (>=0.1.0,<0.2.0)
16
+ Requires-Dist: programgarden-finance (>=0.1.0,<0.2.0)
17
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
18
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ 외부 플러그인 모음
@@ -0,0 +1 @@
1
+ 외부 플러그인 모음
@@ -0,0 +1,126 @@
1
+ """Programgarden community package root.
2
+
3
+ This module implements a LangChain-style lazy import surface: names listed in
4
+ ``_MODULE_MAP`` are imported from their submodules on first access via
5
+ ``__getattr__``. Use ``getCommunityTool(name)`` to dynamically retrieve a class
6
+ by its id string.
7
+ """
8
+
9
+ from importlib import metadata
10
+ import warnings
11
+ from typing import Any, List, Optional
12
+
13
+ try:
14
+ __version__ = metadata.version(__package__)
15
+ except metadata.PackageNotFoundError:
16
+ __version__ = ""
17
+ del metadata
18
+
19
+ __all__ = [
20
+ "SMAGoldenDeadCross",
21
+ "StockSplitFunds",
22
+ "getCommunityTool",
23
+ ]
24
+
25
+
26
+ def _warn_on_import(name: str, replacement: Optional[str] = None) -> None:
27
+ """Emit a warning when a name is imported from the package root.
28
+
29
+ This mirrors LangChain's behaviour: importing many symbols from the root
30
+ is convenient but we suggest importing from the actual submodule.
31
+ """
32
+ if replacement:
33
+ warnings.warn(
34
+ f"Importing {name} from programgarden_community root is discouraged; "
35
+ f"prefer {replacement}",
36
+ stacklevel=3,
37
+ )
38
+
39
+
40
+ def __getattr__(name: str) -> Any:
41
+ """LangChain-style explicit lazy import surface.
42
+
43
+ Each supported top-level name is handled with an explicit branch that
44
+ imports the real implementation from its submodule on first access.
45
+ """
46
+ if name == "SMAGoldenDeadCross":
47
+ from programgarden_community.overseas_stock.strategy_conditions.sma_golden_dead import (
48
+ SMAGoldenDeadCross,
49
+ )
50
+
51
+ _warn_on_import(
52
+ name,
53
+ replacement=(
54
+ "programgarden_community.overseas_stock.strategy_conditions.sma_golden_dead.SMAGoldenDeadCross"
55
+ ),
56
+ )
57
+
58
+ globals()[name] = SMAGoldenDeadCross
59
+ return SMAGoldenDeadCross
60
+
61
+ if name == "StockSplitFunds":
62
+ from programgarden_community.overseas_stock.new_buy_conditions.stock_split_funds import (
63
+ StockSplitFunds,
64
+ )
65
+
66
+ _warn_on_import(
67
+ name,
68
+ replacement=(
69
+ "programgarden_community.overseas_stock.new_buy_conditions.stock_split_funds.StockSplitFunds"
70
+ ),
71
+ )
72
+
73
+ globals()[name] = StockSplitFunds
74
+ return StockSplitFunds
75
+
76
+ if name == "TrailingStopManager":
77
+ from programgarden_community.overseas_stock.new_sell_conditions.trailing_stop import (
78
+ TrailingStopManager,
79
+ )
80
+
81
+ _warn_on_import(
82
+ name,
83
+ replacement=(
84
+ "programgarden_community.overseas_stock.new_sell_conditions.trailing_stop.TrailingStopManager"
85
+ ),
86
+ )
87
+
88
+ globals()[name] = TrailingStopManager
89
+ return TrailingStopManager
90
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
91
+
92
+
93
+ def __dir__() -> List[str]:
94
+ shown: List[str] = list(globals().keys())
95
+ shown.extend(["SMAGoldenDeadCross", "StockSplitFunds"])
96
+ return sorted(shown)
97
+
98
+
99
+ def getCommunityCondition(class_name: str) -> Any:
100
+ """Dynamically import and return a class by its registered id.
101
+
102
+ This mirrors the explicit-branch behaviour above and avoids importing the
103
+ entire package root.
104
+ """
105
+ if class_name == "SMAGoldenDeadCross":
106
+ from programgarden_community.overseas_stock.strategy_conditions.sma_golden_dead import (
107
+ SMAGoldenDeadCross,
108
+ )
109
+
110
+ return SMAGoldenDeadCross
111
+
112
+ if class_name == "StockSplitFunds":
113
+ from programgarden_community.overseas_stock.new_buy_conditions.stock_split_funds import (
114
+ StockSplitFunds,
115
+ )
116
+
117
+ return StockSplitFunds
118
+
119
+ if class_name == "TrailingStopManager":
120
+ from programgarden_community.overseas_stock.new_sell_conditions.trailing_stop import (
121
+ TrailingStopManager,
122
+ )
123
+
124
+ return TrailingStopManager
125
+
126
+ raise ValueError(f"{class_name} is not a valid community tool.")
@@ -0,0 +1,102 @@
1
+ """
2
+ 균등하게 분할매수하기 위한 자금배분
3
+ """
4
+ from typing import List, Optional
5
+ from programgarden_core import (
6
+ BaseBuyOverseasStock,
7
+ BaseBuyOverseasStockResponseType,
8
+ )
9
+ from programgarden_finance import LS, g3101
10
+
11
+
12
+ class StockSplitFunds(BaseBuyOverseasStock):
13
+
14
+ id: str = "StockSplitFunds"
15
+ description: str = "주식 분할 자금"
16
+ securities: List[str] = ["ls-sec.co.kr"]
17
+
18
+ def __init__(
19
+ self,
20
+ appkey: Optional[str] = None,
21
+ appsecretkey: Optional[str] = None,
22
+ percent_balance: float = 10.0,
23
+ max_symbols: float = 5,
24
+ ):
25
+ """
26
+ 주식 분할 자금 초기화
27
+
28
+ Args:
29
+ appkey (Optional[str]): LS증권 앱키
30
+ appsecretkey (Optional[str]): LS증권 앱시크릿키
31
+ percent_balance (float): 현재 예수금의 몇 %를 사용할지
32
+ max_symbols (float): 최대 몇 종목까지 매수할지
33
+ """
34
+ super().__init__()
35
+
36
+ self.appkey = appkey
37
+ self.appsecretkey = appsecretkey
38
+ self.percent_balance = percent_balance
39
+ self.max_symbols = max_symbols
40
+
41
+ async def execute(self) -> List[BaseBuyOverseasStockResponseType]:
42
+
43
+ ls = LS.get_instance()
44
+ if not ls.is_logged_in():
45
+ await ls.async_login(
46
+ appkey=self.appkey,
47
+ appsecretkey=self.appsecretkey
48
+ )
49
+
50
+ fcurr_dps = self.fcurr_dps * self.percent_balance
51
+
52
+ # 종목당 최대 매수 금액
53
+ per_max_amt = round(fcurr_dps / self.max_symbols, 2)
54
+
55
+ orders: List[BaseBuyOverseasStockResponseType] = []
56
+ for symbol in self.available_symbols:
57
+ if len(orders) >= self.max_symbols:
58
+ break
59
+
60
+ exchcd = symbol.get("exchcd")
61
+ symbol = symbol.get("symbol")
62
+
63
+ cur = await ls.overseas_stock().market().g3101(
64
+ body=g3101.G3101InBlock(
65
+ keysymbol=exchcd+symbol,
66
+ exchcd=exchcd,
67
+ symbol=symbol
68
+ )
69
+ ).req_async()
70
+
71
+ # 계산된 금액으로 살 수 있는 최대 수량(정수)
72
+ price = round(float(cur.block.price), 1)
73
+ if price <= 0:
74
+ buy_qty = 0
75
+ else:
76
+ buy_qty = int(per_max_amt // price)
77
+ if buy_qty < 1:
78
+ continue
79
+
80
+ # 주문 생성
81
+ order: BaseBuyOverseasStockResponseType = {
82
+ "success": True,
83
+ "ord_ptn_code": "02",
84
+ "ord_mkt_code": exchcd,
85
+ "isu_no": symbol,
86
+ "ord_qty": buy_qty,
87
+ "ovrs_ord_prc": price,
88
+ "ordprc_ptn_code": "00",
89
+ "brk_tp_code": "01"
90
+ }
91
+
92
+ orders.append(order)
93
+
94
+ return orders
95
+
96
+ async def on_real_order_receive(self, order_type, response):
97
+ print(f"매수 Community 주문 데이터 수신: {order_type}")
98
+
99
+
100
+ __all__ = [
101
+ "StockSplitFunds"
102
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+ from programgarden_core import (
5
+ BaseSellOverseasStock, BaseSellOverseasStockResponseType
6
+ )
7
+
8
+
9
+ class BasicLossCutManager(BaseSellOverseasStock):
10
+
11
+ id: str = "BasicLossCutManager"
12
+ description: str = "기본 손절매 매니저"
13
+ securities: List[str] = ["ls-sec.co.kr"]
14
+
15
+ def __init__(
16
+ self,
17
+ losscut: float = -5,
18
+ ):
19
+ """
20
+ 기본 손절매 매니저 초기화
21
+
22
+ Args:
23
+ losscut (float): 손절매 비율
24
+ """
25
+ super().__init__()
26
+
27
+ self.losscut = losscut
28
+
29
+ async def execute(self) -> List[BaseSellOverseasStockResponseType]:
30
+
31
+ results: List[BaseSellOverseasStockResponseType] = []
32
+ for held in self.held_symbols:
33
+ shtn_isu_no = held.get("ShtnIsuNo")
34
+ fcurr_mkt_code = held.get("FcurrMktCode")
35
+ keysymbol = fcurr_mkt_code + shtn_isu_no
36
+
37
+ rnl_rat = float(held.get("RnlRat", 0))
38
+
39
+ if rnl_rat <= self.losscut:
40
+ print(f"손절매 조건 충족: {keysymbol} 손익률={rnl_rat:.2f}% <= {self.losscut}%")
41
+
42
+ result: BaseSellOverseasStockResponseType = {
43
+ "success": True,
44
+ "ord_ptn_code": "01",
45
+ "ord_mkt_code": fcurr_mkt_code,
46
+ "shtn_isu_no": shtn_isu_no,
47
+ "ord_qty": held.get("AstkSellAbleQty", 0),
48
+ "ovrs_ord_prc": 0.0,
49
+ "ordprc_ptn_code": "03",
50
+ "crcy_code": "USD",
51
+ "pnl_rat": rnl_rat,
52
+ "pchs_amt": held.get("PchsAmt", 0.0),
53
+ }
54
+ results.append(result)
55
+
56
+ return results
@@ -0,0 +1,256 @@
1
+ """
2
+ Moving average golden/dead cross detection conditions
3
+
4
+ 1) Observed a dead->golden where golden_price > dead_price (candidate)
5
+ 2) The golden occurred within the most recent 2 data points
6
+ 3) The latest alignment is golden (still maintained)
7
+ """
8
+ from dataclasses import dataclass
9
+ from typing import List, Literal, Optional, TypedDict
10
+ from programgarden_core import (
11
+ BaseConditionResponseType,
12
+ BaseCondition,
13
+ )
14
+ from programgarden_finance import LS, g3204
15
+
16
+
17
+ class ChartDay(TypedDict):
18
+ """
19
+ 차트 일별 데이터 타입
20
+ """
21
+ date: str # 날짜
22
+ price: float # 종가
23
+
24
+
25
+ @dataclass
26
+ class SMASignal:
27
+ """
28
+ SMA 신호 데이터 클래스
29
+ """
30
+ cross: Literal["golden", "dead", "none"]
31
+ price: float
32
+ volume: float
33
+ date: str
34
+
35
+
36
+ class SMAGoldenDeadCross(BaseCondition):
37
+ """
38
+ SMA 해외 주식 클래스
39
+ """
40
+
41
+ id: str = "SMAGoldenDeadCross"
42
+ description: str = """
43
+ Moving average golden/dead cross detection conditions
44
+
45
+ 1) Observed a dead->golden where golden_price > dead_price (candidate)
46
+ 2) The golden occurred within the most recent 2 data points
47
+ 3) The latest alignment is golden (still maintained)
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ start_date: Optional[str],
53
+ end_date: Optional[str],
54
+ long_period: int,
55
+ short_period: int,
56
+ time_category: Literal["months", "weeks", "days"] = "days",
57
+ days_prices: Optional[list[ChartDay]] = None,
58
+ use_ls: bool = True,
59
+ alignment: Literal["golden", "dead"] = "golden",
60
+ appkey: Optional[str] = None,
61
+ appsecretkey: Optional[str] = None,
62
+ ):
63
+ """
64
+ SMA 해외 주식 클래스 초기화
65
+
66
+ Args:
67
+ symbol (str): 종목 코드, ex) "82TSLA"
68
+ exchcd (str): LS증권에서 사용하는 거래소 코드, ex) "82"
69
+ start_date (Optional[str]): 시작 날짜, ex) 20230101
70
+ end_date (Optional[str]): 종료 날짜, ex) 20231231
71
+ long_period: int: 롱 포지션 기간
72
+ short_period: int: 숏 포지션 기간
73
+ time_category (Literal["months", "weeks", "days", "minutes"]): 카테고리
74
+ days_prices (Optional[list[ChartDay]]): 기간 동안의 종가 리스트
75
+ use_ls (bool): LS증권 데이터를 사용할지 여부
76
+ alignment (Literal["golden", "dead"]): 정렬 방식
77
+ appkey (Optional[str]): LS증권 앱키
78
+ appsecretkey (Optional[str]): LS증권 앱시크릿키
79
+ """
80
+ super().__init__()
81
+
82
+ if not use_ls and not days_prices:
83
+ raise ValueError("LS증권 데이터를 사용하지 않는 경우 days_prices가 필요합니다.")
84
+
85
+ if use_ls and (not appkey or not appsecretkey):
86
+ raise ValueError("LS증권 데이터를 사용하려면 appkey와 appsecretkey가 필요합니다.")
87
+
88
+ self.start_date = start_date
89
+ self.end_date = end_date
90
+
91
+ # store provided SMA periods and helper list used by calculator
92
+ self.long_period = long_period
93
+ self.short_period = short_period
94
+ # list of SMA periods used throughout the calculator (short -> long)
95
+ self.sma_periods = [self.short_period, self.long_period]
96
+
97
+ # transition detection state:
98
+ # last observed dead cross price (None if not seen yet)
99
+ self._last_dead_price = None
100
+ # whether a valid dead->golden transition (golden price > dead price) was observed
101
+ self._transition_detected = False
102
+
103
+ self.time_category = time_category
104
+ self.days_prices = days_prices if days_prices is not None else []
105
+ self.use_ls = use_ls
106
+ self.alignment = alignment
107
+ self.appkey = appkey
108
+ self.appsecretkey = appsecretkey
109
+
110
+ async def execute(self) -> BaseConditionResponseType:
111
+ """
112
+ SMA 해외 주식 전략을 실행합니다.
113
+ 이 메서드는 비동기적으로 실행됩니다.
114
+ """
115
+
116
+ self.ls = LS.get_instance()
117
+ if not self.ls.token_manager.is_token_available():
118
+ await self.ls.async_login()
119
+ exchcd = self.symbol.get("exchcd")
120
+ symbol = self.symbol.get("symbol")
121
+
122
+ gubun = "2"
123
+ if self.time_category == "days":
124
+ gubun = "2"
125
+ elif self.time_category == "weeks":
126
+ gubun = "3"
127
+ elif self.time_category == "months":
128
+ gubun = "4"
129
+
130
+ m_g3204 = self.ls.overseas_stock().chart().g3204(
131
+ g3204.G3204InBlock(
132
+ sdate=self.start_date,
133
+ edate=self.end_date,
134
+ keysymbol=exchcd + symbol,
135
+ exchcd=exchcd,
136
+ symbol=symbol,
137
+ gubun=gubun,
138
+ qrycnt=500,
139
+ )
140
+ )
141
+
142
+ occurs_result = await m_g3204.occurs_req_async()
143
+ all_blocks: List[g3204.blocks.G3204OutBlock1] = []
144
+ for response in occurs_result:
145
+ all_blocks.extend(response.block1)
146
+ all_blocks.sort(key=lambda x: x.date)
147
+
148
+ self.all_signal = self._calculator(all_blocks)
149
+
150
+ return {
151
+ "condition_id": self.id,
152
+ # "success": getattr(self, "_transition_detected", False),
153
+ "success": True,
154
+ "exchange": self.symbol.get("exchcd", None),
155
+ "symbol": self.symbol.get("symbol", None),
156
+ "data": self.all_signal
157
+ }
158
+
159
+ def _calculator(
160
+ self,
161
+ blocks: List[g3204.blocks.G3204OutBlock1],
162
+ ) -> List[SMASignal]:
163
+ """
164
+ 응답을 처리하는 메소드
165
+
166
+ Args:
167
+ response (g3204.blocks.G3204Response): 응답 객체
168
+ status (RequestStatus): 요청 상태
169
+ """
170
+
171
+ signals = []
172
+
173
+ # For simplicity we only handle a single short vs long SMA pair
174
+ short_period = int(self.short_period)
175
+ long_period = int(self.long_period)
176
+ max_period = max(self.sma_periods)
177
+
178
+ # tracking variables to support the new requirement:
179
+ # - golden must have occurred within the last 2 data points
180
+ # - golden alignment must still be maintained at the end
181
+ last_dead_price = None
182
+ golden_index = None
183
+ golden_price = None
184
+ transition_candidate = False
185
+ last_alignment_golden = False
186
+
187
+ for idx, block in enumerate(blocks):
188
+ # SMA 계산을 위한 데이터 수집
189
+ if not hasattr(self, 'price_history'):
190
+ self.price_history = []
191
+
192
+ self.price_history.append(block.close)
193
+
194
+ cross_type = "none"
195
+
196
+ # 현재 SMA 계산 (데이터가 충분할 때)
197
+ if len(self.price_history) >= max_period:
198
+ current_short = sum(self.price_history[-short_period:]) / short_period
199
+ current_long = sum(self.price_history[-long_period:]) / long_period
200
+
201
+ # update last alignment state (used after loop)
202
+ last_alignment_golden = current_short > current_long
203
+
204
+ # 이전 시점 SMA가 존재할 때만 크로스 판정
205
+ if len(self.price_history) > max_period:
206
+ prev_short = sum(self.price_history[-short_period-1:-1]) / short_period
207
+ prev_long = sum(self.price_history[-long_period-1:-1]) / long_period
208
+
209
+ # 정렬 상태: strict 비교 (현재 단기 > 장기 => 골든 정렬)
210
+ all_golden_aligned = current_short > current_long
211
+ all_dead_aligned = current_short < current_long
212
+
213
+ # 최근 크로스 발생 여부
214
+ recent_golden_cross = (prev_short <= prev_long and current_short > current_long)
215
+ recent_dead_cross = (prev_short >= prev_long and current_short < current_long)
216
+
217
+ if all_golden_aligned and recent_golden_cross:
218
+ cross_type = "golden"
219
+ elif all_dead_aligned and recent_dead_cross:
220
+ cross_type = "dead"
221
+
222
+ sma_signal = SMASignal(
223
+ cross=cross_type,
224
+ price=block.close,
225
+ volume=block.volume,
226
+ date=block.date,
227
+ )
228
+
229
+ # transition tracking: record dead/golden with their indices and prices
230
+ if cross_type == "dead":
231
+ last_dead_price = block.close
232
+ # keep compatibility with instance attribute
233
+ self._last_dead_price = last_dead_price
234
+ elif cross_type == "golden":
235
+ golden_index = idx
236
+ golden_price = block.close
237
+ # golden must be strictly higher than last dead price to be candidate
238
+ if last_dead_price is not None and golden_price > last_dead_price:
239
+ transition_candidate = True
240
+
241
+ signals.append(sma_signal)
242
+
243
+ # Final evaluation: mark transition detected only when
244
+ # 1) we observed a dead->golden where golden_price > dead_price (candidate)
245
+ # 2) the golden occurred within the most recent 2 data points
246
+ # 3) the latest alignment is golden (still maintained)
247
+ self._transition_detected = False
248
+ if transition_candidate and golden_index is not None:
249
+ # number of data points since the golden event
250
+ if len(blocks) - golden_index <= 2 and last_alignment_golden:
251
+ self._transition_detected = True
252
+
253
+ # persist last dead price for external visibility
254
+ self._last_dead_price = last_dead_price
255
+
256
+ return signals
@@ -0,0 +1,26 @@
1
+ [project]
2
+ authors = [
3
+ {"name" = "프로그램동산","email" = "coding@programgarden.com"}
4
+ ]
5
+ homepage = "https://programgarden.com"
6
+ requires-python = ">=3.9"
7
+ name = "programgarden-community"
8
+ version = "0.1.1"
9
+ description = "증권 분석에 필요한 외부 전략 모아둔 플러그인"
10
+ readme = "README.md"
11
+
12
+ [tool.poetry]
13
+ packages = [
14
+ { include = "programgarden_community" }
15
+ ]
16
+
17
+ [tool.poetry.dependencies]
18
+ pydantic = ">=2.11.7,<3.0.0"
19
+ programgarden-finance = "^0.1.0"
20
+ programgarden-core = "^0.1.0"
21
+ croniter = "^6.0.0"
22
+ python-dotenv = "^1.1.1"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
26
+ build-backend = "poetry.core.masonry.api"