programgarden 1.37.7__tar.gz → 1.37.9__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-1.37.7 → programgarden-1.37.9}/PKG-INFO +3 -3
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/context.py +26 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/fill_reconciler.py +1 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/workflow_position_tracker.py +85 -7
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/executor.py +37 -1
- programgarden-1.37.9/programgarden/valuation.py +51 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/pyproject.toml +3 -3
- {programgarden-1.37.7 → programgarden-1.37.9}/README.md +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/binding_validator.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/client.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/code_worker.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/node_runner.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resolver.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resource/context.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.37.7 → programgarden-1.37.9}/programgarden/validation_recommender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 1.37.
|
|
3
|
+
Version: 1.37.9
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -16,8 +16,8 @@ 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
18
|
Requires-Dist: programgarden-community (>=1.15.3,<2.0.0)
|
|
19
|
-
Requires-Dist: programgarden-core (>=1.28.
|
|
20
|
-
Requires-Dist: programgarden-finance (>=1.9.
|
|
19
|
+
Requires-Dist: programgarden-core (>=1.28.2,<2.0.0)
|
|
20
|
+
Requires-Dist: programgarden-finance (>=1.9.8,<2.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.0,<3.0.0)
|
|
@@ -1968,6 +1968,7 @@ class ExecutionContext:
|
|
|
1968
1968
|
current_prices: Dict[str, float],
|
|
1969
1969
|
account_positions: Optional[Dict[str, Any]],
|
|
1970
1970
|
currency: Optional[str] = "USD",
|
|
1971
|
+
account_valuation: Optional[Dict[str, Any]] = None,
|
|
1971
1972
|
) -> None:
|
|
1972
1973
|
"""Notify all listeners about workflow P&L update (확장 버전).
|
|
1973
1974
|
|
|
@@ -2015,6 +2016,12 @@ class ExecutionContext:
|
|
|
2015
2016
|
product=product,
|
|
2016
2017
|
start_date=None, # 전체 기간
|
|
2017
2018
|
)
|
|
2019
|
+
from .valuation import workflow_valuation_snapshot
|
|
2020
|
+
|
|
2021
|
+
workflow_valuation = workflow_valuation_snapshot(
|
|
2022
|
+
base_workflow_result,
|
|
2023
|
+
account_positions if account_positions is not None else ({} if account_valuation else None),
|
|
2024
|
+
now, product)
|
|
2018
2025
|
|
|
2019
2026
|
# 2. 계좌 전체 수익률 계산
|
|
2020
2027
|
account_result = self._calculate_account_pnl(
|
|
@@ -2098,6 +2105,8 @@ class ExecutionContext:
|
|
|
2098
2105
|
"anomaly_count": base_workflow_result.get("anomaly_count", 0),
|
|
2099
2106
|
"currency": currency,
|
|
2100
2107
|
"personal_metrics": personal_metrics,
|
|
2108
|
+
"account_valuation": account_valuation,
|
|
2109
|
+
"workflow_valuation": workflow_valuation,
|
|
2101
2110
|
|
|
2102
2111
|
# 신규 필드: 워크플로우 상품별
|
|
2103
2112
|
"workflow_overseas_stock_pnl_rate": base_workflow_result.get("workflow_overseas_stock_pnl_rate"),
|
|
@@ -2865,6 +2874,19 @@ class ExecutionContext:
|
|
|
2865
2874
|
logger.warning(f"Failed to update order fill price: {e}")
|
|
2866
2875
|
return False
|
|
2867
2876
|
|
|
2877
|
+
async def _annotate_workflow_fill_currency(self, *, currency: str, **fill_kwargs: Any) -> bool:
|
|
2878
|
+
"""Preserve post-refresh currency without replaying the AS1 execution."""
|
|
2879
|
+
tracker = self._workflow_position_tracker
|
|
2880
|
+
if tracker is None:
|
|
2881
|
+
return False
|
|
2882
|
+
fill_kwargs["fill_time"] = fill_kwargs.get("fill_time") or UNKNOWN_FILL_TIME
|
|
2883
|
+
try:
|
|
2884
|
+
return await tracker.annotate_fill_currency(currency=currency, **fill_kwargs)
|
|
2885
|
+
except ValueError:
|
|
2886
|
+
# Unusable/conflicting identity must never cause another fill write.
|
|
2887
|
+
logger.warning("Cannot attach currency evidence to the retained execution")
|
|
2888
|
+
return False
|
|
2889
|
+
|
|
2868
2890
|
async def record_workflow_fill(
|
|
2869
2891
|
self,
|
|
2870
2892
|
order_no: str,
|
|
@@ -2879,6 +2901,7 @@ class ExecutionContext:
|
|
|
2879
2901
|
*,
|
|
2880
2902
|
execution_id: Optional[str | int] = None,
|
|
2881
2903
|
account_avg_price: Optional[float] = None,
|
|
2904
|
+
currency: Optional[str] = None,
|
|
2882
2905
|
) -> str:
|
|
2883
2906
|
"""Record fill event for FIFO position tracking.
|
|
2884
2907
|
|
|
@@ -2904,6 +2927,7 @@ class ExecutionContext:
|
|
|
2904
2927
|
execution_id: Optional broker execution number, preserved for durable replay detection.
|
|
2905
2928
|
account_avg_price: Optional account average purchase price for this
|
|
2906
2929
|
symbol at fill time; only a workflow sell's residual tail uses it.
|
|
2930
|
+
currency: Explicit broker unit at fill time; no product default.
|
|
2907
2931
|
|
|
2908
2932
|
Returns:
|
|
2909
2933
|
분류 결과: "workflow" | "manual" | "unknown_api" | "pending"
|
|
@@ -2976,6 +3000,8 @@ class ExecutionContext:
|
|
|
2976
3000
|
# 재시도에도 유지한다 — identity 축이 아니고, 매도 잔량 추정의
|
|
2977
3001
|
# 유일한 근거다.
|
|
2978
3002
|
base_kwargs["account_avg_price"] = account_avg_price
|
|
3003
|
+
if currency is not None:
|
|
3004
|
+
base_kwargs["currency"] = currency
|
|
2979
3005
|
|
|
2980
3006
|
try:
|
|
2981
3007
|
result = await self._workflow_position_tracker.record_fill(
|
|
@@ -227,6 +227,7 @@ async def reconcile_workflow_fills(
|
|
|
227
227
|
# 빈 값이면 트래커가 '우리 주문과 일치하는가' 로만 분류한다.
|
|
228
228
|
commda_code="",
|
|
229
229
|
account_avg_price=avg_price,
|
|
230
|
+
currency=hit.get("currency"),
|
|
230
231
|
)
|
|
231
232
|
except Exception as exc:
|
|
232
233
|
report.errors.append(f"record_fill_failed[{order_no}]: {exc}")
|
{programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -183,6 +183,9 @@ class PendingFill:
|
|
|
183
183
|
# read from the broker snapshot before it refreshes. Used only to estimate a
|
|
184
184
|
# workflow sell's residual tail (see _process_sell_fifo); None → no estimate.
|
|
185
185
|
account_avg_price: Optional[float] = None
|
|
186
|
+
# An explicit broker unit, never inferred from the product or exchange.
|
|
187
|
+
currency: Optional[str] = None
|
|
188
|
+
currency_conflict: bool = False
|
|
186
189
|
|
|
187
190
|
|
|
188
191
|
class ExecutionIdentityError(ValueError):
|
|
@@ -535,6 +538,12 @@ class WorkflowPositionTracker:
|
|
|
535
538
|
for column in ("execution_id", "normalized_order_no", "execution_payload"):
|
|
536
539
|
if column not in columns:
|
|
537
540
|
cursor.execute(f"ALTER TABLE trade_history ADD COLUMN {column} TEXT")
|
|
541
|
+
if "currency" not in columns:
|
|
542
|
+
cursor.execute("ALTER TABLE trade_history ADD COLUMN currency TEXT")
|
|
543
|
+
if "currency_conflict" not in columns:
|
|
544
|
+
cursor.execute(
|
|
545
|
+
"ALTER TABLE trade_history ADD COLUMN currency_conflict INTEGER NOT NULL DEFAULT 0"
|
|
546
|
+
)
|
|
538
547
|
# Additive columns for the account-average-price sell estimate. A
|
|
539
548
|
# workflow sell that empties the strategy's own lots and still has a
|
|
540
549
|
# tail records that tail here (the residual quantity, the account
|
|
@@ -722,6 +731,7 @@ class WorkflowPositionTracker:
|
|
|
722
731
|
*,
|
|
723
732
|
execution_id: Optional[str | int] = None,
|
|
724
733
|
account_avg_price: Optional[float] = None,
|
|
734
|
+
currency: Optional[str] = None,
|
|
725
735
|
) -> str:
|
|
726
736
|
"""
|
|
727
737
|
체결 기록 및 FIFO 처리
|
|
@@ -743,6 +753,8 @@ class WorkflowPositionTracker:
|
|
|
743
753
|
account_avg_price: Optional account average purchase price for this
|
|
744
754
|
symbol at fill time. Only a workflow sell whose own lots run out
|
|
745
755
|
uses it, to estimate the residual tail. None → no estimate.
|
|
756
|
+
currency: Explicit broker currency for this fill. Missing/invalid
|
|
757
|
+
values remain unknown and never change fill identity or amounts.
|
|
746
758
|
execution_id: Optional broker execution identity. Positive numeric
|
|
747
759
|
strings/integers ignore padding; opaque strings retain case.
|
|
748
760
|
None, blank, and zero mean no identity and retain legacy replay
|
|
@@ -765,6 +777,7 @@ class WorkflowPositionTracker:
|
|
|
765
777
|
exchange=exchange, side=side, quantity=quantity, price=price,
|
|
766
778
|
fill_time=fill_time, commda_code=commda_code, received_at=datetime.now(),
|
|
767
779
|
execution_id=execution_id, account_avg_price=account_avg_price,
|
|
780
|
+
currency=self._currency_code(currency),
|
|
768
781
|
)
|
|
769
782
|
identity = self._execution_key(fill)
|
|
770
783
|
notifications: list = []
|
|
@@ -879,6 +892,59 @@ class WorkflowPositionTracker:
|
|
|
879
892
|
"commda_code": fill.commda_code,
|
|
880
893
|
}
|
|
881
894
|
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _currency_code(value: Any) -> Optional[str]:
|
|
897
|
+
if not isinstance(value, str):
|
|
898
|
+
return None
|
|
899
|
+
value = value.strip().upper()
|
|
900
|
+
return value if re.fullmatch(r"[A-Z]{3}", value) else None
|
|
901
|
+
|
|
902
|
+
async def annotate_fill_currency(self, *, currency: str, **fill_kwargs: Any) -> bool:
|
|
903
|
+
"""Attach a contemporaneous broker unit to an exact retained execution.
|
|
904
|
+
|
|
905
|
+
Called after the existing AS1 account refresh, never to reconstruct old
|
|
906
|
+
history. No identity means no annotation. Check all canonical facts and
|
|
907
|
+
hold the ledger write lock, but do not create a fill, consume a lot, emit
|
|
908
|
+
another fill notification or alter the deduplication payload. Conflicting
|
|
909
|
+
units permanently make the unit unknown until separately investigated.
|
|
910
|
+
"""
|
|
911
|
+
unit = self._currency_code(currency)
|
|
912
|
+
if unit is None or self.product != "overseas_stock":
|
|
913
|
+
return False
|
|
914
|
+
fill = PendingFill(**fill_kwargs, received_at=datetime.now(), currency=unit)
|
|
915
|
+
identity = self._execution_key(fill)
|
|
916
|
+
if identity is None:
|
|
917
|
+
return False
|
|
918
|
+
changed = False
|
|
919
|
+
async with self._buffer_lock:
|
|
920
|
+
for pending in self._pending_fills.values():
|
|
921
|
+
if self._execution_key(pending) == identity:
|
|
922
|
+
self._assert_same_execution(pending, fill)
|
|
923
|
+
if pending.currency_conflict or pending.currency == unit:
|
|
924
|
+
return False
|
|
925
|
+
pending.currency_conflict = pending.currency is not None
|
|
926
|
+
pending.currency = None if pending.currency_conflict else unit
|
|
927
|
+
return True
|
|
928
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
929
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
930
|
+
cursor = conn.cursor()
|
|
931
|
+
if self._find_execution(cursor, fill) is None:
|
|
932
|
+
return False
|
|
933
|
+
where = """product=? AND provider=? AND trading_mode=? AND order_date=?
|
|
934
|
+
AND normalized_order_no=? AND execution_id=?"""
|
|
935
|
+
stored, conflict = cursor.execute(
|
|
936
|
+
f"SELECT currency, currency_conflict FROM trade_history WHERE {where}", identity
|
|
937
|
+
).fetchone()
|
|
938
|
+
if not conflict and stored != unit:
|
|
939
|
+
cursor.execute(
|
|
940
|
+
f"UPDATE trade_history SET currency=?, currency_conflict=? WHERE {where}",
|
|
941
|
+
(unit if stored is None else None, int(stored is not None), *identity),
|
|
942
|
+
)
|
|
943
|
+
changed = True
|
|
944
|
+
if changed:
|
|
945
|
+
self.fill_revision += 1
|
|
946
|
+
return changed
|
|
947
|
+
|
|
882
948
|
def _assert_same_execution(self, previous: PendingFill, fill: PendingFill) -> None:
|
|
883
949
|
if self._execution_facts(previous) != self._execution_facts(fill):
|
|
884
950
|
raise ExecutionIdentityConflictError("Conflicting fill facts for an existing execution identity")
|
|
@@ -984,8 +1050,9 @@ class WorkflowPositionTracker:
|
|
|
984
1050
|
(product, provider, order_no, order_date, symbol, exchange, side, quantity, price,
|
|
985
1051
|
fill_datetime, classification, commda_code, realized_pnl, trading_mode, created_at,
|
|
986
1052
|
execution_id, normalized_order_no, execution_payload,
|
|
987
|
-
unmatched_qty, estimate_basis_price, estimate_source, estimated_pnl
|
|
988
|
-
|
|
1053
|
+
unmatched_qty, estimate_basis_price, estimate_source, estimated_pnl,
|
|
1054
|
+
currency, currency_conflict)
|
|
1055
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
989
1056
|
""", (
|
|
990
1057
|
self.product, self.provider,
|
|
991
1058
|
fill.order_no, fill.order_date, fill.symbol, fill.exchange,
|
|
@@ -996,6 +1063,8 @@ class WorkflowPositionTracker:
|
|
|
996
1063
|
estimate["estimate_basis_price"] if estimate else None,
|
|
997
1064
|
estimate["estimate_source"] if estimate else None,
|
|
998
1065
|
estimate["estimated_pnl"] if estimate else None,
|
|
1066
|
+
fill.currency,
|
|
1067
|
+
int(fill.currency_conflict),
|
|
999
1068
|
))
|
|
1000
1069
|
|
|
1001
1070
|
conn.commit()
|
|
@@ -1960,8 +2029,9 @@ class WorkflowPositionTracker:
|
|
|
1960
2029
|
def personal_metrics(self) -> Dict[str, Any]:
|
|
1961
2030
|
"""Read retained workflow executions without claiming account performance.
|
|
1962
2031
|
|
|
1963
|
-
Historical FIFO rows have no currency or fee evidence.
|
|
1964
|
-
|
|
2032
|
+
Historical FIFO rows have no currency or fee evidence. New rows can carry
|
|
2033
|
+
an explicit broker unit; old units are never backfilled. Keep amounts
|
|
2034
|
+
per symbol/exchange and reject contradictory units or an incomplete basis.
|
|
1965
2035
|
Futures FIFO is not a monetary ledger. No equity/MDD basis is inferred.
|
|
1966
2036
|
|
|
1967
2037
|
Version 2 adds closed-trade outcomes. One closed trade = one sell fill,
|
|
@@ -1986,8 +2056,8 @@ class WorkflowPositionTracker:
|
|
|
1986
2056
|
plus the estimated tail). Any other residual is an incomplete basis.
|
|
1987
2057
|
|
|
1988
2058
|
Counts aggregate across symbols because a count carries no currency.
|
|
1989
|
-
Amounts do not:
|
|
1990
|
-
|
|
2059
|
+
Amounts do not: the API may sum groups only when every group carries the
|
|
2060
|
+
same explicit currency. Missing evidence never inherits an account unit.
|
|
1991
2061
|
So gross amounts stay inside each group, and the top-level profit/loss
|
|
1992
2062
|
ratio is published only when a single group carries the whole ledger —
|
|
1993
2063
|
the one case where "the currency is the same" needs no evidence.
|
|
@@ -2025,6 +2095,8 @@ class WorkflowPositionTracker:
|
|
|
2025
2095
|
|
|
2026
2096
|
realized = []
|
|
2027
2097
|
for (symbol, exchange), fills in sorted(groups.items()):
|
|
2098
|
+
units = {self._currency_code(fill.get("currency")) for fill in fills}
|
|
2099
|
+
unit_conflict = any(fill.get("currency_conflict") for fill in fills)
|
|
2028
2100
|
reason = None
|
|
2029
2101
|
amount = None
|
|
2030
2102
|
outcome = None
|
|
@@ -2036,6 +2108,9 @@ class WorkflowPositionTracker:
|
|
|
2036
2108
|
reason = "futures_fifo_not_monetary"
|
|
2037
2109
|
elif not symbol:
|
|
2038
2110
|
reason = "missing_symbol"
|
|
2111
|
+
elif len(units - {None}) > 1 or unit_conflict:
|
|
2112
|
+
# Contradictory units cannot establish a comparable FIFO basis.
|
|
2113
|
+
reason = "incomplete_fifo_basis"
|
|
2039
2114
|
else:
|
|
2040
2115
|
# The old mixed_fifo_ownership pre-check is gone: a workflow sell
|
|
2041
2116
|
# now consumes only workflow lots, so it can never have realized a
|
|
@@ -2137,7 +2212,10 @@ class WorkflowPositionTracker:
|
|
|
2137
2212
|
status = "available"
|
|
2138
2213
|
basis = "fifo"
|
|
2139
2214
|
estimated_quantity = 0
|
|
2140
|
-
|
|
2215
|
+
unit = (next(iter(units)) if len(units) == 1 and None not in units
|
|
2216
|
+
and not unit_conflict
|
|
2217
|
+
and self.product != "overseas_futures" else None)
|
|
2218
|
+
entry = {"symbol": symbol, "exchange": exchange, "currency": unit,
|
|
2141
2219
|
"amount": amount, "status": status, "reason": reason,
|
|
2142
2220
|
"basis": basis, "estimated_quantity": estimated_quantity}
|
|
2143
2221
|
# A rejected group publishes no outcome: its trades are unknown, and
|
|
@@ -165,6 +165,24 @@ def _whole_unit_quantity(value: Any) -> Optional[int]:
|
|
|
165
165
|
return int(number) if number.is_integer() else None
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def _stock_position_fill_currency(position: Any, symbol: str, market_code: str) -> Optional[str]:
|
|
169
|
+
"""Use only explicitly populated COSOQ00201 position fields for this symbol.
|
|
170
|
+
|
|
171
|
+
AS1 has no currency. StockPositionItem defaults to USD, so a model default
|
|
172
|
+
is insufficient evidence. No quote currency or account-wide default is used.
|
|
173
|
+
"""
|
|
174
|
+
from programgarden_finance.ls.overseas_stock.extension.models import StockPositionItem
|
|
175
|
+
|
|
176
|
+
if not isinstance(position, StockPositionItem):
|
|
177
|
+
return None
|
|
178
|
+
if not {"symbol", "market_code", "currency_code"} <= position.model_fields_set:
|
|
179
|
+
return None
|
|
180
|
+
if position.symbol != symbol or not market_code or position.market_code != market_code:
|
|
181
|
+
return None
|
|
182
|
+
unit = position.currency_code.strip().upper()
|
|
183
|
+
return unit if re.fullmatch(r"[A-Z]{3}", unit) else None
|
|
184
|
+
|
|
185
|
+
|
|
168
186
|
def _usable_execution_identity(order_no: Any, order_date: Any) -> bool:
|
|
169
187
|
"""체결 프레임의 (주문번호, 주문일자)가 원장 execution identity 키로 쓸 수 있나.
|
|
170
188
|
|
|
@@ -4881,10 +4899,12 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4881
4899
|
# 읽는다. 캐시는 아직 이번 매도가 반영되기 전이라 '매도 직전 평단'.
|
|
4882
4900
|
# 캐시에 없으면 None → 추정 없음(오늘처럼 unavailable).
|
|
4883
4901
|
account_avg_price = None
|
|
4902
|
+
currency = None
|
|
4884
4903
|
if tracker_info and "tracker" in tracker_info:
|
|
4885
4904
|
acct_tracker = tracker_info["tracker"]
|
|
4886
4905
|
positions = getattr(acct_tracker, "_positions", None)
|
|
4887
4906
|
pos = positions.get(symbol) if isinstance(positions, dict) else None
|
|
4907
|
+
currency = _stock_position_fill_currency(pos, symbol, market_code)
|
|
4888
4908
|
buy_price = getattr(pos, "buy_price", None) if pos is not None else None
|
|
4889
4909
|
if buy_price is not None:
|
|
4890
4910
|
try:
|
|
@@ -4892,7 +4912,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4892
4912
|
except (TypeError, ValueError):
|
|
4893
4913
|
account_avg_price = None
|
|
4894
4914
|
|
|
4895
|
-
|
|
4915
|
+
fill_kwargs = dict(
|
|
4896
4916
|
order_no=order_no,
|
|
4897
4917
|
order_date=order_date,
|
|
4898
4918
|
symbol=symbol,
|
|
@@ -4912,12 +4932,20 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4912
4932
|
),
|
|
4913
4933
|
account_avg_price=account_avg_price,
|
|
4914
4934
|
)
|
|
4935
|
+
await context.record_workflow_fill(currency=currency, **fill_kwargs)
|
|
4915
4936
|
|
|
4916
4937
|
# AccountTracker refresh로 PnL 이벤트 강제 트리거
|
|
4917
4938
|
if tracker_info and "tracker" in tracker_info:
|
|
4918
4939
|
tracker = tracker_info["tracker"]
|
|
4919
4940
|
if hasattr(tracker, 'refresh_now'):
|
|
4920
4941
|
await tracker.refresh_now()
|
|
4942
|
+
positions = getattr(tracker, "_positions", None)
|
|
4943
|
+
pos = positions.get(symbol) if isinstance(positions, dict) else None
|
|
4944
|
+
refreshed_currency = _stock_position_fill_currency(pos, symbol, market_code)
|
|
4945
|
+
if refreshed_currency is not None:
|
|
4946
|
+
await context._annotate_workflow_fill_currency(
|
|
4947
|
+
currency=refreshed_currency, **fill_kwargs
|
|
4948
|
+
)
|
|
4921
4949
|
logger.debug(f"📊 AccountTracker refreshed after fill")
|
|
4922
4950
|
|
|
4923
4951
|
asyncio.run_coroutine_threadsafe(record_and_refresh(), loop)
|
|
@@ -5582,6 +5610,8 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
5582
5610
|
"current_price": float(pos_item.current_price) if hasattr(pos_item, 'current_price') else 0,
|
|
5583
5611
|
"pnl_rate": float(pos_item.pnl_rate) if hasattr(pos_item, 'pnl_rate') else 0,
|
|
5584
5612
|
"product": product, # 상품 유형 (overseas_stock)
|
|
5613
|
+
"currency": pos_item.currency_code.strip().upper()
|
|
5614
|
+
if "currency_code" in pos_item.model_fields_set else None,
|
|
5585
5615
|
}
|
|
5586
5616
|
|
|
5587
5617
|
self._start_background_task(context,
|
|
@@ -5592,6 +5622,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
5592
5622
|
current_prices=current_prices,
|
|
5593
5623
|
account_positions=account_positions if account_positions else None,
|
|
5594
5624
|
currency=pnl_info.currency if hasattr(pnl_info, 'currency') else "USD",
|
|
5625
|
+
account_valuation=tracker.get_valuation_snapshot(),
|
|
5595
5626
|
),
|
|
5596
5627
|
notification=True,
|
|
5597
5628
|
)
|
|
@@ -17005,9 +17036,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
17005
17036
|
p = float(getattr(item, "OvrsExecPrc", 0) or getattr(item, "OvrsOrdPrc", 0) or 0)
|
|
17006
17037
|
row = out.setdefault(order_no, {
|
|
17007
17038
|
"filled_qty": 0, "_amount": 0.0, "symbol": "", "fill_time": "",
|
|
17039
|
+
"_currencies": set(),
|
|
17008
17040
|
})
|
|
17009
17041
|
row["filled_qty"] += q
|
|
17010
17042
|
row["_amount"] += q * p
|
|
17043
|
+
row["_currencies"].add(str(item.CrcyCode or "").strip().upper())
|
|
17011
17044
|
# 🔴 LS 는 TR 마다 같은 뜻의 필드 이름이 다르고, 이 블록에는 `ShtnIsuNo`
|
|
17012
17045
|
# (단축종목번호)와 `IsuNo`(종목번호)가 **둘 다** 있다. 이 저장소의 관행은
|
|
17013
17046
|
# ShtnIsuNo 우선·IsuNo 폴백이다(잔고·체결 파싱이 전부 그렇게 한다).
|
|
@@ -17023,6 +17056,9 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
17023
17056
|
for row in out.values():
|
|
17024
17057
|
qty = row["filled_qty"]
|
|
17025
17058
|
row["avg_price"] = (row.pop("_amount") / qty) if qty > 0 else 0.0
|
|
17059
|
+
units = row.pop("_currencies")
|
|
17060
|
+
unit = next(iter(units)) if len(units) == 1 else ""
|
|
17061
|
+
row["currency"] = unit if re.fullmatch(r"[A-Z]{3}", unit) else None
|
|
17026
17062
|
return out
|
|
17027
17063
|
except Exception as e:
|
|
17028
17064
|
context.log("debug", f"COSAQ00102 batch fill query exception: {e}", node_id)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Currency-scoped open workflow valuation, independent of realized trades."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, is_dataclass
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def workflow_valuation_snapshot(result, account_positions, observed_at, product):
|
|
9
|
+
if product != "overseas_stock" or not isinstance(account_positions, dict):
|
|
10
|
+
return None
|
|
11
|
+
positions = result.get("workflow_positions")
|
|
12
|
+
if not isinstance(positions, list):
|
|
13
|
+
return None
|
|
14
|
+
if positions and result.get("workflow_pnl_rate") is None:
|
|
15
|
+
return None
|
|
16
|
+
groups = {}
|
|
17
|
+
seen = set()
|
|
18
|
+
for item in positions:
|
|
19
|
+
row = asdict(item) if is_dataclass(item) else item
|
|
20
|
+
if not isinstance(row, dict):
|
|
21
|
+
return None
|
|
22
|
+
symbol = row.get("symbol")
|
|
23
|
+
account = account_positions.get(symbol)
|
|
24
|
+
if not isinstance(account, dict) or symbol in seen:
|
|
25
|
+
return None
|
|
26
|
+
seen.add(symbol)
|
|
27
|
+
currency = account.get("currency")
|
|
28
|
+
if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency):
|
|
29
|
+
return None
|
|
30
|
+
values = {}
|
|
31
|
+
for key in ("quantity", "avg_price", "current_price", "pnl_amount"):
|
|
32
|
+
value = row.get(key)
|
|
33
|
+
if value is None or isinstance(value, bool):
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
values[key] = Decimal(str(value))
|
|
37
|
+
except InvalidOperation:
|
|
38
|
+
return None
|
|
39
|
+
if not values[key].is_finite() or (key != "pnl_amount" and values[key] <= 0):
|
|
40
|
+
return None
|
|
41
|
+
# Valuation is an estimate from the workflow's own retained cost basis.
|
|
42
|
+
pnl = values["pnl_amount"]
|
|
43
|
+
if abs(pnl - (values["current_price"] - values["avg_price"]) * values["quantity"]) > Decimal("0.0001"):
|
|
44
|
+
return None
|
|
45
|
+
group = groups.setdefault(currency, {"earned": Decimal(0), "lost": Decimal(0), "net": Decimal(0)})
|
|
46
|
+
group["earned"] += max(pnl, Decimal(0))
|
|
47
|
+
group["lost"] += max(-pnl, Decimal(0))
|
|
48
|
+
group["net"] = group["earned"] - group["lost"]
|
|
49
|
+
return {"version": 1, "product": product, "basis": "workflow_open_positions_gross",
|
|
50
|
+
"observed_at": observed_at.isoformat(),
|
|
51
|
+
"groups": [{"currency": currency, **group} for currency, group in sorted(groups.items())]}
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden"
|
|
8
|
-
version = "1.37.
|
|
8
|
+
version = "1.37.9"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -36,8 +36,8 @@ litellm = ">=1.40.0"
|
|
|
36
36
|
# 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
|
|
37
37
|
# (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
|
|
38
38
|
# 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
|
|
39
|
-
programgarden-core = "^1.28.
|
|
40
|
-
programgarden-finance = "^1.9.
|
|
39
|
+
programgarden-core = "^1.28.2"
|
|
40
|
+
programgarden-finance = "^1.9.8"
|
|
41
41
|
programgarden-community = "^1.15.3"
|
|
42
42
|
|
|
43
43
|
[tool.poetry.group.dev.dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.37.7 → programgarden-1.37.9}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|