programgarden 2.6.7__tar.gz → 2.7.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.
- {programgarden-2.6.7 → programgarden-2.7.1}/PKG-INFO +4 -4
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_snapshot.py +6 -3
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/workflow_position_tracker.py +2 -1
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/deep_fixtures.py +12 -6
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/executor.py +230 -38
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_external.py +60 -1
- programgarden-2.7.1/programgarden/replay_responders.py +148 -0
- programgarden-2.7.1/programgarden/replay_tape.py +182 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resolver.py +3 -3
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/validation_replay.py +28 -1
- {programgarden-2.6.7 → programgarden-2.7.1}/pyproject.toml +4 -4
- {programgarden-2.6.7 → programgarden-2.7.1}/README.md +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/binding_provenance.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/binding_validator.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/client.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/code_worker.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/context.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/sqlite_scope.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_contract_info.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_exchange.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_history.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/incremental_build.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/node_runner.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/paper_futures_business_date.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/ranking_nodes.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_events.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_orders.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_scenarios.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_semantics.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_sources.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_sqlite.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/context.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/validation_recommender.py +0 -0
- {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/valuation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.1
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -15,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
|
|
|
15
15
|
Requires-Dist: croniter (>=6.0.0,<7.0.0)
|
|
16
16
|
Requires-Dist: litellm (>=1.40.0)
|
|
17
17
|
Requires-Dist: lxml (>=6.0.2,<7.0.0)
|
|
18
|
-
Requires-Dist: programgarden-community (>=2.3.
|
|
19
|
-
Requires-Dist: programgarden-core (>=2.
|
|
20
|
-
Requires-Dist: programgarden-finance (>=2.0.
|
|
18
|
+
Requires-Dist: programgarden-community (>=2.3.2,<3.0.0)
|
|
19
|
+
Requires-Dist: programgarden-core (>=2.7.0,<3.0.0)
|
|
20
|
+
Requires-Dist: programgarden-finance (>=2.0.6,<3.0.0)
|
|
21
21
|
Requires-Dist: psutil (>=6.0.0,<7.0.0)
|
|
22
22
|
Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
|
|
23
23
|
Requires-Dist: pydantic (>=2.0.1,<3.0.0)
|
|
@@ -61,19 +61,22 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
|
|
|
61
61
|
from programgarden_finance.ls.korea_stock.extension.valuation import collect_domestic_trading_positions
|
|
62
62
|
from programgarden_finance.ls.korea_stock.extension.account_queries import collect_domestic_open_orders
|
|
63
63
|
|
|
64
|
+
# The collector returns t0424 rows (trade-basis balance, chegb=2), not
|
|
65
|
+
# CSPAQ12300 rows: the symbol is expcode and the held quantity janqty,
|
|
66
|
+
# the same fields the server startup gate reads.
|
|
64
67
|
_, rows = await collect_domestic_trading_positions(ls.korea_stock().accno())
|
|
65
68
|
for row in rows:
|
|
66
|
-
symbol = normalized_symbol(str(required(row, "
|
|
69
|
+
symbol = normalized_symbol(str(required(row, "expcode")), product)
|
|
67
70
|
if symbol in positions:
|
|
68
71
|
raise ReconciliationUnavailable("ambiguous_position_rows")
|
|
69
|
-
positions[symbol] = quantity(required(row, "
|
|
72
|
+
positions[symbol] = quantity(required(row, "janqty"))
|
|
70
73
|
await asyncio.sleep(2)
|
|
71
74
|
pending = await collect_domestic_open_orders(ls.korea_stock().accno())
|
|
72
75
|
# t0425 is the documented current-day inquiry. Retain that observation
|
|
73
76
|
# date for exact ledger matching; never widen it to yesterday's order.
|
|
74
77
|
for row in pending:
|
|
75
78
|
row["order_date"] = day
|
|
76
|
-
source = "
|
|
79
|
+
source = "t0424+t0425"
|
|
77
80
|
elif product == "overseas_stock":
|
|
78
81
|
from programgarden_finance import COSOQ00201, COSAQ00102
|
|
79
82
|
|
{programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -2725,7 +2725,7 @@ class WorkflowPositionTracker:
|
|
|
2725
2725
|
with sqlite3.connect(self.db_path) as conn:
|
|
2726
2726
|
cursor = conn.cursor()
|
|
2727
2727
|
cursor.execute("""
|
|
2728
|
-
SELECT order_no, order_date, symbol, exchange, side, quantity
|
|
2728
|
+
SELECT order_no, order_date, symbol, exchange, side, quantity, product
|
|
2729
2729
|
FROM workflow_orders
|
|
2730
2730
|
WHERE trading_mode = ? AND (price = 0 OR price IS NULL)
|
|
2731
2731
|
ORDER BY created_at DESC
|
|
@@ -2739,6 +2739,7 @@ class WorkflowPositionTracker:
|
|
|
2739
2739
|
"exchange": row[3],
|
|
2740
2740
|
"side": row[4],
|
|
2741
2741
|
"quantity": row[5],
|
|
2742
|
+
"product": row[6],
|
|
2742
2743
|
}
|
|
2743
2744
|
for row in cursor.fetchall()
|
|
2744
2745
|
]
|
|
@@ -79,7 +79,8 @@ def _config_symbols(config: Dict[str, Any]) -> Any:
|
|
|
79
79
|
_FIXTURE_BARS = 64
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0
|
|
82
|
+
def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0,
|
|
83
|
+
minute_step: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
83
84
|
"""Build a deterministic OHLCV series for one symbol (oldest bar first).
|
|
84
85
|
|
|
85
86
|
The close path rises for the first ~⅔ of the window then declines into the
|
|
@@ -93,6 +94,9 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
|
|
|
93
94
|
price = base
|
|
94
95
|
for i in range(n):
|
|
95
96
|
day = _FIXTURE_ANCHOR + timedelta(days=i)
|
|
97
|
+
if minute_step:
|
|
98
|
+
# Minute bars carry YYYYMMDDHHMMSS stamps, like the live g3203/o3103 rows.
|
|
99
|
+
day = _FIXTURE_ANCHOR.replace(hour=9, minute=30) + timedelta(minutes=i * minute_step)
|
|
96
100
|
# Rise toward the peak, then ease back down — a gentle mean-reverting arc.
|
|
97
101
|
if i <= peak:
|
|
98
102
|
price = base + i * 0.8
|
|
@@ -101,7 +105,7 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
|
|
|
101
105
|
price = max(price, base * 0.5)
|
|
102
106
|
bars.append(
|
|
103
107
|
{
|
|
104
|
-
"date": day.strftime("%Y%m%d"),
|
|
108
|
+
"date": day.strftime("%Y%m%d%H%M%S" if minute_step else "%Y%m%d"),
|
|
105
109
|
"open": round(price - 0.5, 2),
|
|
106
110
|
"high": round(price + 1.0, 2),
|
|
107
111
|
"low": round(price - 1.0, 2),
|
|
@@ -112,7 +116,8 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
|
|
|
112
116
|
return bars
|
|
113
117
|
|
|
114
118
|
|
|
115
|
-
def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None
|
|
119
|
+
def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None,
|
|
120
|
+
minute_step: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
116
121
|
"""Build the per-symbol ``{symbol, exchange, time_series:[bars]}`` list that
|
|
117
122
|
HistoricalDataNode emits at runtime. Downstream ConditionNode auto-iterates
|
|
118
123
|
this list, so each entry must carry the keys its ``items.extract`` reads
|
|
@@ -125,7 +130,7 @@ def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None) -> Lis
|
|
|
125
130
|
{
|
|
126
131
|
"symbol": entry["symbol"],
|
|
127
132
|
"exchange": entry["exchange"],
|
|
128
|
-
"time_series": _ohlcv_series(entry["symbol"], base=100.0 + idx * 10),
|
|
133
|
+
"time_series": _ohlcv_series(entry["symbol"], base=100.0 + idx * 10, minute_step=minute_step),
|
|
129
134
|
}
|
|
130
135
|
)
|
|
131
136
|
return entries
|
|
@@ -179,7 +184,8 @@ def market_data_fixture(config: Dict[str, Any], symbols_raw: Any = None, *, prod
|
|
|
179
184
|
return {"values": values}
|
|
180
185
|
|
|
181
186
|
|
|
182
|
-
def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None
|
|
187
|
+
def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None, *,
|
|
188
|
+
minute_step: Optional[int] = None) -> Dict[str, Any]:
|
|
183
189
|
"""HistoricalDataNode deep fixture.
|
|
184
190
|
|
|
185
191
|
Real shape (executor.py ``HistoricalDataNodeExecutor.execute`` return):
|
|
@@ -195,7 +201,7 @@ def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) ->
|
|
|
195
201
|
"""
|
|
196
202
|
if symbols_raw is None:
|
|
197
203
|
symbols_raw = config.get("symbol") or _config_symbols(config)
|
|
198
|
-
entries = _time_series_entries(config, symbols_raw)
|
|
204
|
+
entries = _time_series_entries(config, symbols_raw, minute_step=minute_step)
|
|
199
205
|
single = entries[0] if len(entries) == 1 else None
|
|
200
206
|
return {
|
|
201
207
|
"value": single,
|
|
@@ -1012,6 +1012,12 @@ class LSClientManager:
|
|
|
1012
1012
|
(ls_instance, success: bool, error_message: str | None)
|
|
1013
1013
|
"""
|
|
1014
1014
|
from programgarden_finance import LS
|
|
1015
|
+
from programgarden_finance.ls.replay_transport import active_transport, replay_client
|
|
1016
|
+
|
|
1017
|
+
# Offline replay (ls_tape) only: a client whose REST TRs are answered from
|
|
1018
|
+
# the recording. No login, no token provider, never cached or reused.
|
|
1019
|
+
if active_transport() is not None:
|
|
1020
|
+
return replay_client(paper_trading), True, None
|
|
1015
1021
|
|
|
1016
1022
|
# 이미 해당 product의 인스턴스가 있고 로그인된 경우
|
|
1017
1023
|
if product in cls._instances:
|
|
@@ -1102,6 +1108,54 @@ def _resolve_broker_credentials(
|
|
|
1102
1108
|
return appkey, appsecret, paper_trading
|
|
1103
1109
|
|
|
1104
1110
|
|
|
1111
|
+
FUTURES_FILL_PRICE_LOOKBACK_DAYS = 1 # today and the previous local day
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _futures_fill_price_memo(context: Any) -> Dict[str, Any]:
|
|
1115
|
+
"""Per-run memory of the futures fill-price recovery (kept on the context)."""
|
|
1116
|
+
memo = getattr(context, "_futures_fill_price_memo", None)
|
|
1117
|
+
if not isinstance(memo, dict):
|
|
1118
|
+
memo = {"attempted": set(), "failures": 0}
|
|
1119
|
+
try:
|
|
1120
|
+
setattr(context, "_futures_fill_price_memo", memo)
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
return memo
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _futures_fill_price_candidates(orders: Any, context: Any) -> Dict[str, Dict[str, Any]]:
|
|
1127
|
+
"""Unpriced futures orders worth one CIDBQ01800 read, by order date.
|
|
1128
|
+
|
|
1129
|
+
Keeps orders of a futures product (rows without a product are kept: the
|
|
1130
|
+
caller already chose the product), dated today or yesterday, with a broker
|
|
1131
|
+
order number, that this run has not looked up yet.
|
|
1132
|
+
"""
|
|
1133
|
+
from datetime import date, timedelta
|
|
1134
|
+
from programgarden_finance.ls.overseas_futureoption.extension.execution_history import (
|
|
1135
|
+
normalize_broker_identity,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
today = date.today()
|
|
1139
|
+
window = {
|
|
1140
|
+
(today - timedelta(days=days)).strftime("%Y%m%d")
|
|
1141
|
+
for days in range(FUTURES_FILL_PRICE_LOOKBACK_DAYS + 1)
|
|
1142
|
+
}
|
|
1143
|
+
attempted = _futures_fill_price_memo(context)["attempted"]
|
|
1144
|
+
wanted: Dict[str, Dict[str, Any]] = {}
|
|
1145
|
+
for order in orders or []:
|
|
1146
|
+
if not isinstance(order, Mapping):
|
|
1147
|
+
continue
|
|
1148
|
+
product = order.get("product")
|
|
1149
|
+
if product is not None and product not in FUTURES_PRODUCTS:
|
|
1150
|
+
continue
|
|
1151
|
+
identity = normalize_broker_identity(order.get("order_no"))
|
|
1152
|
+
order_date = str(order.get("order_date") or "")
|
|
1153
|
+
if not identity or order_date not in window or (order_date, identity) in attempted:
|
|
1154
|
+
continue
|
|
1155
|
+
wanted.setdefault(order_date, {})[identity] = order
|
|
1156
|
+
return wanted
|
|
1157
|
+
|
|
1158
|
+
|
|
1105
1159
|
def ensure_ls_login(
|
|
1106
1160
|
appkey: str,
|
|
1107
1161
|
appsecret: str,
|
|
@@ -5582,7 +5636,20 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
5582
5636
|
orders_without_price = context.get_workflow_orders_without_fill_price()
|
|
5583
5637
|
if not orders_without_price:
|
|
5584
5638
|
return
|
|
5585
|
-
|
|
5639
|
+
if product == "overseas_futures":
|
|
5640
|
+
# Bounded (2.7.0): only today's/yesterday's futures orders that
|
|
5641
|
+
# this run has not read yet. The broker node re-runs every
|
|
5642
|
+
# scheduled cycle, and unpriced rows stay in the ledger forever
|
|
5643
|
+
# (an order cancelled before any fill keeps price 0), so without
|
|
5644
|
+
# this every cycle would log in and read again.
|
|
5645
|
+
orders_without_price = [
|
|
5646
|
+
order
|
|
5647
|
+
for by_identity in _futures_fill_price_candidates(orders_without_price, context).values()
|
|
5648
|
+
for order in by_identity.values()
|
|
5649
|
+
]
|
|
5650
|
+
if not orders_without_price:
|
|
5651
|
+
return
|
|
5652
|
+
|
|
5586
5653
|
context.log("info", f"Found {len(orders_without_price)} orders without fill price - syncing from history", node_id)
|
|
5587
5654
|
|
|
5588
5655
|
from programgarden_finance import LS
|
|
@@ -5717,37 +5784,82 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
5717
5784
|
context: ExecutionContext,
|
|
5718
5785
|
node_id: str,
|
|
5719
5786
|
) -> None:
|
|
5720
|
-
"""
|
|
5787
|
+
"""Recover missing futures fill prices from CIDBQ01800 order rows.
|
|
5788
|
+
|
|
5789
|
+
Bounded, because the broker node re-runs every scheduled cycle and
|
|
5790
|
+
starts this read again while unpriced rows stay in the ledger forever
|
|
5791
|
+
(an order cancelled before any fill keeps price 0):
|
|
5792
|
+
|
|
5793
|
+
- only futures orders dated today or yesterday (local dates, the clock
|
|
5794
|
+
the futures order path stamps the ledger with);
|
|
5795
|
+
- each order is looked up at most once per run: one CIDBQ01800 read per
|
|
5796
|
+
order date that has an unpriced order this run has not read yet, so
|
|
5797
|
+
a new unpriced order on a date already read causes one more read;
|
|
5798
|
+
- the first failed read of a run is a warning, later ones are debug.
|
|
5799
|
+
|
|
5800
|
+
A row supplies a price only for the same broker order — OvrsFutsOrdNo
|
|
5801
|
+
compared as the integer identity, because CIDBQ01800 zero-pads it
|
|
5802
|
+
("0000000614") while the order TRs answer "614" — on the same symbol
|
|
5803
|
+
when the ledger row has one, with an executed quantity and a positive
|
|
5804
|
+
AbrdFutsExecPrc (LS paper 2026-10-15: ``ExecQty=1``,
|
|
5805
|
+
``AbrdFutsExecPrc="24114.00000000000"``). The stored order number and
|
|
5806
|
+
date are written back unchanged.
|
|
5807
|
+
|
|
5808
|
+
Until 2.7.0 this called a method that does not exist, so every attempt
|
|
5809
|
+
ended in a logged warning before any request and no price was ever
|
|
5810
|
+
recovered.
|
|
5811
|
+
"""
|
|
5721
5812
|
try:
|
|
5722
|
-
from programgarden_finance import
|
|
5723
|
-
|
|
5724
|
-
# 오늘 날짜 기준 체결내역 조회
|
|
5725
|
-
today = datetime.now().strftime("%Y%m%d")
|
|
5726
|
-
|
|
5727
|
-
response = ls.overseas_futureoption().accno().해외선물체결내역조회(
|
|
5728
|
-
body=CIDBQ01800.CIDBQ01800InBlock(
|
|
5729
|
-
QryDt=today,
|
|
5730
|
-
)
|
|
5813
|
+
from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01800.blocks import (
|
|
5814
|
+
CIDBQ01800InBlock1,
|
|
5731
5815
|
)
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5816
|
+
from programgarden_finance.ls.overseas_futureoption.extension.execution_history import (
|
|
5817
|
+
normalize_broker_identity,
|
|
5818
|
+
)
|
|
5819
|
+
|
|
5820
|
+
wanted = _futures_fill_price_candidates(orders, context)
|
|
5821
|
+
if not wanted:
|
|
5735
5822
|
return
|
|
5736
|
-
|
|
5737
|
-
|
|
5823
|
+
memo = _futures_fill_price_memo(context)
|
|
5824
|
+
|
|
5825
|
+
accno = ls.overseas_futureoption().accno()
|
|
5738
5826
|
fill_history = []
|
|
5739
|
-
for
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5827
|
+
for order_date, by_identity in wanted.items():
|
|
5828
|
+
# Mark before the read: a failed or empty answer is not retried
|
|
5829
|
+
# by the next cycles of this run.
|
|
5830
|
+
memo["attempted"].update((order_date, identity) for identity in by_identity)
|
|
5831
|
+
result = await accno.CIDBQ01800(body=CIDBQ01800InBlock1(
|
|
5832
|
+
RecCnt=1, IsuCodeVal="", OrdDt=order_date, ThdayTpCode="",
|
|
5833
|
+
OrdStatCode="0", BnsTpCode="0", QryTpCode="1", OrdPtnCode="00",
|
|
5834
|
+
OvrsDrvtFnoTpCode="A",
|
|
5835
|
+
)).req_async()
|
|
5836
|
+
if getattr(result, "error_msg", None):
|
|
5837
|
+
level = "warning" if memo["failures"] == 0 else "debug"
|
|
5838
|
+
memo["failures"] += 1
|
|
5839
|
+
context.log(level, f"CIDBQ01800 fill price read failed ({order_date}): {result.error_msg}", node_id)
|
|
5840
|
+
continue
|
|
5841
|
+
for row in getattr(result, "block2", None) or []:
|
|
5842
|
+
order = by_identity.get(normalize_broker_identity(getattr(row, "OvrsFutsOrdNo", None)))
|
|
5843
|
+
price = getattr(row, "AbrdFutsExecPrc", None)
|
|
5844
|
+
executed = getattr(row, "ExecQty", 0) or 0
|
|
5845
|
+
if (order is None or isinstance(price, bool) or not isinstance(price, (int, float))
|
|
5846
|
+
or not math.isfinite(price) or price <= 0 or executed <= 0):
|
|
5847
|
+
continue
|
|
5848
|
+
ledger_symbol = str(order.get("symbol") or "").strip().upper()
|
|
5849
|
+
row_symbol = str(getattr(row, "IsuCodeVal", "") or "").strip().upper()
|
|
5850
|
+
if ledger_symbol and row_symbol and ledger_symbol != row_symbol:
|
|
5851
|
+
continue
|
|
5852
|
+
fill_history.append({
|
|
5853
|
+
"order_no": order["order_no"],
|
|
5854
|
+
"order_date": order["order_date"],
|
|
5855
|
+
"fill_price": float(price),
|
|
5856
|
+
})
|
|
5857
|
+
|
|
5746
5858
|
if fill_history:
|
|
5747
5859
|
updated = context.sync_workflow_fill_prices_from_history(fill_history)
|
|
5748
5860
|
if updated > 0:
|
|
5749
5861
|
context.log("info", f"Synced {updated} order fill prices from futures history", node_id)
|
|
5750
|
-
|
|
5862
|
+
|
|
5751
5863
|
except Exception as e:
|
|
5752
5864
|
context.log("warning", f"Failed to sync futures fill prices: {e}", node_id)
|
|
5753
5865
|
|
|
@@ -12486,7 +12598,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12486
12598
|
HistoricalDataNode executor - 과거 OHLCV 데이터 조회
|
|
12487
12599
|
|
|
12488
12600
|
LS Finance Chart API를 사용하여 과거 차트 데이터를 조회합니다.
|
|
12489
|
-
- Overseas stock: g3204 (daily/weekly/monthly/yearly
|
|
12601
|
+
- Overseas stock: g3204 (daily/weekly/monthly/yearly), g3203 (1/5/10/15/30/60-minute bars)
|
|
12490
12602
|
- 해외선물: o3108 (일봉), o3103 (분봉)
|
|
12491
12603
|
"""
|
|
12492
12604
|
|
|
@@ -12512,10 +12624,12 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12512
12624
|
# The broker-specific fetch repeats this guard for direct/legacy callers.
|
|
12513
12625
|
connection = config.get("connection")
|
|
12514
12626
|
product = connection.get("product") if isinstance(connection, dict) else None
|
|
12627
|
+
stock_minute_step = None
|
|
12515
12628
|
if node_type == "OverseasStockHistoricalDataNode" or (
|
|
12516
12629
|
node_type == "HistoricalDataNode" and product in (None, "overseas_stock")
|
|
12517
12630
|
):
|
|
12518
|
-
self.
|
|
12631
|
+
tr_code, selector = self._stock_history_request(config.get("interval", "1d"))
|
|
12632
|
+
stock_minute_step = selector if tr_code == "g3203" else None
|
|
12519
12633
|
if node_type == "OverseasFuturesHistoricalDataNode" or (
|
|
12520
12634
|
node_type == "HistoricalDataNode" and product in FUTURES_PRODUCTS
|
|
12521
12635
|
):
|
|
@@ -12536,7 +12650,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12536
12650
|
_seed = _cfg_sym or _in_sym
|
|
12537
12651
|
except Exception:
|
|
12538
12652
|
_seed = None
|
|
12539
|
-
fixture = _df.historical_data_fixture(config, _seed)
|
|
12653
|
+
fixture = _df.historical_data_fixture(config, _seed, minute_step=stock_minute_step)
|
|
12540
12654
|
override = context.get_deep_fixture(node_id, node_type)
|
|
12541
12655
|
return _df.apply_override(fixture, override)
|
|
12542
12656
|
|
|
@@ -12765,17 +12879,38 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12765
12879
|
# 파싱 실패 시 오늘 날짜 반환
|
|
12766
12880
|
return datetime.now().strftime("%Y%m%d")
|
|
12767
12881
|
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
12771
|
-
|
|
12772
|
-
|
|
12882
|
+
# LS g3204 period selectors (gubun) and g3203 bar sizes (ncnt, minutes).
|
|
12883
|
+
# Every ncnt below answered rsp_cd 00000 with bars of exactly that step on a
|
|
12884
|
+
# live platform call (2026-10-18, NVDA): 1, 5, 10, 15, 30 and 60.
|
|
12885
|
+
STOCK_PERIOD_GUBUN = {"1d": "2", "1w": "3", "1M": "4", "1Y": "5"}
|
|
12886
|
+
STOCK_MINUTE_NCNT = {"1m": 1, "5m": 5, "10m": 10, "15m": 15, "30m": 30, "60m": 60}
|
|
12887
|
+
|
|
12888
|
+
@classmethod
|
|
12889
|
+
def _stock_history_request(cls, interval: str) -> tuple:
|
|
12890
|
+
"""``("g3204", gubun)`` for a period, ``("g3203", ncnt)`` for minute bars.
|
|
12891
|
+
|
|
12892
|
+
Unknown values are refused instead of relabeling daily bars.
|
|
12893
|
+
"""
|
|
12894
|
+
if isinstance(interval, str) and interval in cls.STOCK_PERIOD_GUBUN:
|
|
12895
|
+
return "g3204", cls.STOCK_PERIOD_GUBUN[interval]
|
|
12896
|
+
if isinstance(interval, str) and interval in cls.STOCK_MINUTE_NCNT:
|
|
12897
|
+
return "g3203", cls.STOCK_MINUTE_NCNT[interval]
|
|
12898
|
+
raise ValueError(
|
|
12899
|
+
"UNSUPPORTED_STOCK_HISTORY_INTERVAL: OverseasStockHistoricalDataNode "
|
|
12900
|
+
"supports 1d, 1w, 1M and 1Y through LS g3204 and 1m, 5m, 10m, 15m, 30m "
|
|
12901
|
+
"and 60m minute bars through LS g3203; select a supported interval explicitly."
|
|
12902
|
+
)
|
|
12903
|
+
|
|
12904
|
+
@classmethod
|
|
12905
|
+
def _stock_history_gubun(cls, interval: str) -> str:
|
|
12906
|
+
"""The g3204 period selector; minute intervals and unknown values are refused."""
|
|
12907
|
+
tr_code, selector = cls._stock_history_request(interval)
|
|
12908
|
+
if tr_code != "g3204":
|
|
12773
12909
|
raise ValueError(
|
|
12774
|
-
"UNSUPPORTED_STOCK_HISTORY_INTERVAL:
|
|
12775
|
-
"
|
|
12776
|
-
"Intraday g3203 is not supported; select a supported period explicitly."
|
|
12910
|
+
"UNSUPPORTED_STOCK_HISTORY_INTERVAL: g3204 reads 1d, 1w, 1M and 1Y; "
|
|
12911
|
+
f"{interval} is a g3203 minute interval."
|
|
12777
12912
|
)
|
|
12778
|
-
return
|
|
12913
|
+
return selector
|
|
12779
12914
|
|
|
12780
12915
|
async def _fetch_overseas_stock(
|
|
12781
12916
|
self,
|
|
@@ -12789,7 +12924,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12789
12924
|
symbol_exchange_map: Dict[str, str] = None,
|
|
12790
12925
|
symbols_raw: List[Dict[str, Any]] = None,
|
|
12791
12926
|
) -> List[Dict[str, Any]]:
|
|
12792
|
-
"""해외주식 차트 데이터 조회 (g3204)
|
|
12927
|
+
"""해외주식 차트 데이터 조회 (g3204 기간봉 / g3203 분봉)
|
|
12793
12928
|
|
|
12794
12929
|
g3204 API 사용 (sdate, edate, qrycnt 지원):
|
|
12795
12930
|
- g3103: date 기준 최근 30개만 반환 (제한적)
|
|
@@ -12799,7 +12934,9 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12799
12934
|
"""
|
|
12800
12935
|
|
|
12801
12936
|
# Validate before credential access or login, outside the soft-failure handler.
|
|
12802
|
-
|
|
12937
|
+
tr_code, selector = self._stock_history_request(interval)
|
|
12938
|
+
gubun = selector if tr_code == "g3204" else None
|
|
12939
|
+
ncnt = selector if tr_code == "g3203" else None
|
|
12803
12940
|
|
|
12804
12941
|
credential = context.get_credential()
|
|
12805
12942
|
if not credential:
|
|
@@ -12808,6 +12945,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12808
12945
|
|
|
12809
12946
|
try:
|
|
12810
12947
|
from programgarden_finance.ls.overseas_stock.chart.g3204.blocks import G3204InBlock
|
|
12948
|
+
from programgarden_finance.ls.overseas_stock.chart.g3203.blocks import G3203InBlock
|
|
12811
12949
|
|
|
12812
12950
|
ls, success, error = ensure_ls_login(
|
|
12813
12951
|
credential.get("appkey"),
|
|
@@ -12861,6 +12999,25 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12861
12999
|
exchange = symbol_to_exchange.get(symbol) or exchcd_to_exchange.get(exchcd, "NASDAQ")
|
|
12862
13000
|
|
|
12863
13001
|
keysymbol = f"{exchcd}{symbol}"
|
|
13002
|
+
|
|
13003
|
+
if ncnt is not None:
|
|
13004
|
+
bars = await self._fetch_overseas_stock_minutes(
|
|
13005
|
+
api, G3203InBlock(
|
|
13006
|
+
delaygb="R",
|
|
13007
|
+
comp_yn="N",
|
|
13008
|
+
keysymbol=keysymbol,
|
|
13009
|
+
exchcd=exchcd,
|
|
13010
|
+
symbol=symbol,
|
|
13011
|
+
ncnt=ncnt,
|
|
13012
|
+
qrycnt=500, # LS cap for an uncompressed page
|
|
13013
|
+
sdate=start_date,
|
|
13014
|
+
edate=end_date,
|
|
13015
|
+
cts_date="",
|
|
13016
|
+
cts_time="",
|
|
13017
|
+
), symbol, context, node_id,
|
|
13018
|
+
)
|
|
13019
|
+
result_list.append({"symbol": symbol, "exchange": exchange, "time_series": bars})
|
|
13020
|
+
continue
|
|
12864
13021
|
|
|
12865
13022
|
# g3204 API 사용 (sdate, edate, qrycnt 지원)
|
|
12866
13023
|
body = G3204InBlock(
|
|
@@ -12925,6 +13082,41 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12925
13082
|
context.log("error", f"Historical data fetch error: {e}", node_id)
|
|
12926
13083
|
return self._empty_historical_result(symbols, f"i18n:errors.HISTORICAL_DATA_FETCH_ERROR|error={e}")
|
|
12927
13084
|
|
|
13085
|
+
async def _fetch_overseas_stock_minutes(self, api, body, symbol: str, context, node_id: str) -> List[Dict[str, Any]]:
|
|
13086
|
+
"""Read one g3203 page of overseas-stock N-minute bars, oldest first.
|
|
13087
|
+
|
|
13088
|
+
LS answers the newest ``qrycnt`` bars at or before ``edate`` within
|
|
13089
|
+
``sdate..edate`` (observed 2026-10-18: ascending rows, ``tr_cont=Y`` when
|
|
13090
|
+
older bars exist). This reads that first page only, like the futures
|
|
13091
|
+
o3103 path: the requested range is not guaranteed to be complete.
|
|
13092
|
+
``date`` is ``YYYYMMDD`` + ``loctime`` (``YYYYMMDDHHMMSS``, the exchange
|
|
13093
|
+
local time label exactly as LS returns it); ``volume`` is ``exevol``.
|
|
13094
|
+
Bars outside the regular session are kept as LS returns them; no
|
|
13095
|
+
completion or finality flag is added.
|
|
13096
|
+
"""
|
|
13097
|
+
result = await api.chart().g3203(body=body).req_async()
|
|
13098
|
+
if getattr(result, "error_msg", None):
|
|
13099
|
+
context.log("warning", f"g3203 {symbol}: {result.error_msg}", node_id)
|
|
13100
|
+
return []
|
|
13101
|
+
bars = []
|
|
13102
|
+
for item in result.block1 or []:
|
|
13103
|
+
day = getattr(item, "date", "") or ""
|
|
13104
|
+
clock = getattr(item, "loctime", "") or ""
|
|
13105
|
+
bars.append({
|
|
13106
|
+
"date": f"{day}{clock}" if day and clock else day,
|
|
13107
|
+
"open": float(item.open) if item.open else 0,
|
|
13108
|
+
"high": float(item.high) if item.high else 0,
|
|
13109
|
+
"low": float(item.low) if item.low else 0,
|
|
13110
|
+
"close": float(item.close) if item.close else 0,
|
|
13111
|
+
"volume": int(item.exevol) if item.exevol else 0,
|
|
13112
|
+
})
|
|
13113
|
+
bars.sort(key=lambda x: x["date"])
|
|
13114
|
+
if not bars:
|
|
13115
|
+
context.log("warning", f"No minute data for {symbol}", node_id)
|
|
13116
|
+
else:
|
|
13117
|
+
context.log("debug", f"Fetched {len(bars)} g3203 bars for {symbol} (ncnt={body.ncnt})", node_id)
|
|
13118
|
+
return bars
|
|
13119
|
+
|
|
12928
13120
|
async def _fetch_overseas_futures(
|
|
12929
13121
|
self,
|
|
12930
13122
|
symbols: List[str],
|
|
@@ -176,10 +176,54 @@ def source_recording(node_type, config, source, contract, *, as_of, item=None):
|
|
|
176
176
|
return result
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def per_item_key(node_type, item):
|
|
180
|
+
"""The key of one per-item recording in ``record["items"]``.
|
|
181
|
+
|
|
182
|
+
A symbol item is keyed ``EXCHANGE:SYMBOL`` (a domestic node's item without
|
|
183
|
+
an exchange is ``KRX:SYMBOL``, as the domestic schemas accept ``{symbol}``).
|
|
184
|
+
Any other item (a position row without an exchange, a number, a text, a
|
|
185
|
+
nested object) is keyed by its ``content_hash``. Recording authors use this
|
|
186
|
+
function so the worker and the author compute the same key.
|
|
187
|
+
"""
|
|
188
|
+
from programgarden.validation_replay import content_hash
|
|
189
|
+
if isinstance(item, dict) and isinstance(item.get("symbol"), str) and item["symbol"]:
|
|
190
|
+
exchange = item.get("exchange")
|
|
191
|
+
if not exchange and node_type.startswith("KoreaStock"):
|
|
192
|
+
exchange = "KRX"
|
|
193
|
+
if isinstance(exchange, str) and exchange:
|
|
194
|
+
return exchange + ":" + item["symbol"]
|
|
195
|
+
return content_hash(item)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def branch_item(context, config):
|
|
199
|
+
"""``(split_id, index, item)`` for a node running inside a SplitNode branch, else None.
|
|
200
|
+
|
|
201
|
+
The branch runner passes ``_branch_scope = "<split_id>#<index>"`` in the
|
|
202
|
+
node config and publishes the whole array once on the split's ``items``
|
|
203
|
+
port before the first item runs. The item is read from that array by index,
|
|
204
|
+
never from the split's ``item`` port or ``_split_context`` state: parallel
|
|
205
|
+
branches overwrite those single slots while another item is still running.
|
|
206
|
+
"""
|
|
207
|
+
scope = config.get("_branch_scope") if isinstance(config, dict) else None
|
|
208
|
+
if not isinstance(scope, str) or "#" not in scope:
|
|
209
|
+
return None
|
|
210
|
+
split_id, _, raw_index = scope.rpartition("#")
|
|
211
|
+
try:
|
|
212
|
+
index = int(raw_index)
|
|
213
|
+
except ValueError:
|
|
214
|
+
return None
|
|
215
|
+
items = context.get_output(split_id, "items")
|
|
216
|
+
if not isinstance(items, list) or not 0 <= index < len(items):
|
|
217
|
+
raise ContractViolation(split_id, "The split item of this branch is not available",
|
|
218
|
+
"REPLAY_FIXTURE_REQUIRED")
|
|
219
|
+
return split_id, index, items[index]
|
|
220
|
+
|
|
221
|
+
|
|
179
222
|
def external_record(fixture, node_id, node_type, config, context):
|
|
180
223
|
record = fixture.get("nodes", {}).get(node_id)
|
|
181
224
|
if not isinstance(record, dict):
|
|
182
225
|
raise ContractViolation(node_id, "An explicit external recording is required", "REPLAY_FIXTURE_REQUIRED")
|
|
226
|
+
expected_item = None
|
|
183
227
|
if context._iteration_total:
|
|
184
228
|
item = context._iteration_item
|
|
185
229
|
if not isinstance(item, dict) or not isinstance(item.get("symbol"), str) or not item["symbol"]:
|
|
@@ -191,6 +235,21 @@ def external_record(fixture, node_id, node_type, config, context):
|
|
|
191
235
|
if not isinstance(exchange, str) or not exchange:
|
|
192
236
|
raise ContractViolation(node_id, "An overseas recording requires exchange identity", "REPLAY_FIXTURE_REQUIRED")
|
|
193
237
|
record = record.get("items", {}).get(exchange + ":" + item["symbol"])
|
|
238
|
+
expected_item = item
|
|
239
|
+
elif isinstance(record.get("items"), dict):
|
|
240
|
+
# A per-item group inside a SplitNode branch (a basket): select this
|
|
241
|
+
# item's recording. A flat recording (no "items") keeps its meaning: one
|
|
242
|
+
# request identity for every item, with no item bound (item null).
|
|
243
|
+
branch = branch_item(context, config)
|
|
244
|
+
if branch is None:
|
|
245
|
+
raise ContractViolation(node_id, "A per-item recording runs only for an iterated or split item",
|
|
246
|
+
"REPLAY_FIXTURE_REQUIRED")
|
|
247
|
+
split_id, index, expected_item = branch
|
|
248
|
+
key = per_item_key(node_type, expected_item)
|
|
249
|
+
record = record["items"].get(key)
|
|
250
|
+
if not isinstance(record, dict):
|
|
251
|
+
raise ContractViolation(node_id, f"No recording for split item {key} ({split_id}#{index})",
|
|
252
|
+
"REPLAY_FIXTURE_REQUIRED", {"split": split_id, "index": index, "key": key})
|
|
194
253
|
if not isinstance(record, dict):
|
|
195
254
|
raise ContractViolation(node_id, "No matching per-item recording", "REPLAY_FIXTURE_REQUIRED")
|
|
196
255
|
if "request" not in record or "as_of" not in record or "item" not in record:
|
|
@@ -211,7 +270,7 @@ def external_record(fixture, node_id, node_type, config, context):
|
|
|
211
270
|
raise ContractViolation(node_id, _mismatch_message(
|
|
212
271
|
"Recording does not match the shared replay clock", ["as_of"]),
|
|
213
272
|
"REPLAY_FIXTURE_MISMATCH", detail)
|
|
214
|
-
item =
|
|
273
|
+
item = expected_item
|
|
215
274
|
if content_hash(record["item"]) != content_hash(item):
|
|
216
275
|
detail = mismatch_detail(record["item"], item)
|
|
217
276
|
raise ContractViolation(node_id, _mismatch_message(
|