@pineforge/codegen-pyodide 1.2.0 → 1.3.0
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.
- package/LICENSE +19 -3
- package/README.md +2 -1
- package/package.json +1 -1
- package/pineforge_codegen/codegen/base.py +10 -1
- package/pineforge_codegen/codegen/capabilities.py +143 -10
- package/pineforge_codegen/codegen/checked_settings.py +49 -3
- package/pineforge_codegen/codegen/emit_top.py +5 -5
- package/pineforge_codegen/codegen/helpers.py +6 -0
- package/pineforge_codegen/codegen/order_shapes.py +356 -0
- package/pineforge_codegen/codegen/security.py +54 -5
- package/pineforge_codegen/codegen/tables.py +12 -0
- package/pineforge_codegen/codegen/visit_call.py +61 -0
- package/pineforge_codegen/codegen/visit_expr.py +8 -2
- package/pineforge_codegen/codegen/visit_stmt.py +2 -4
- package/pineforge_codegen/support_checker.py +19 -10
- package/pineforge_codegen-1.3.0.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +1 -1
- package/pineforge_codegen-1.2.0.tar.gz +0 -0
package/LICENSE
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
PineForge Source License 1.
|
|
1
|
+
PineForge Source License 1.2
|
|
2
2
|
============================
|
|
3
3
|
|
|
4
|
+
Version 1.2, 2026-10-05. Compared with 1.1, two places change. In the
|
|
5
|
+
Personal Trading section, the last bullet now says that an account a
|
|
6
|
+
proprietary-trading firm or funded-trader program provides or allocates,
|
|
7
|
+
including a challenge, evaluation or simulated account, is not the
|
|
8
|
+
person's own account or capital. In the Commercial Use section, the
|
|
9
|
+
definition of investment capital now covers the capital in such an
|
|
10
|
+
account, real or simulated.
|
|
11
|
+
|
|
4
12
|
Copyright 2025-2026 pineforge, LLC
|
|
5
13
|
|
|
6
14
|
Required Notice: Copyright 2025-2026 pineforge, LLC (https://github.com/pineforge-4pass/pineforge-codegen-oss)
|
|
@@ -134,7 +142,12 @@ For Personal Trading:
|
|
|
134
142
|
program or anyone else outside the person's household provides or
|
|
135
143
|
allocates to the person to trade, and that the provider still owns or
|
|
136
144
|
whose losses the provider bears, is not the person's own capital, even
|
|
137
|
-
if the person shares in the profits.
|
|
145
|
+
if the person shares in the profits. An account that a
|
|
146
|
+
proprietary-trading firm or a funded-trader program provides or
|
|
147
|
+
allocates to the person to trade, including a challenge, evaluation or
|
|
148
|
+
simulated account, whether the capital in it is real or simulated, is
|
|
149
|
+
likewise not the person's own account and is not funded by the
|
|
150
|
+
person's own capital.
|
|
138
151
|
|
|
139
152
|
|
|
140
153
|
## Commercial Use
|
|
@@ -149,7 +162,10 @@ in each case whoever the capital belongs to and whether or not for a fee.
|
|
|
149
162
|
trading, including capital belonging to any other person or entity, such as a
|
|
150
163
|
friend or relative, clients or investors, an endowment, a pension or
|
|
151
164
|
retirement fund, a sovereign or other public fund, or the treasury or reserves
|
|
152
|
-
of a foundation or other organization.
|
|
165
|
+
of a foundation or other organization. The capital in an account that a
|
|
166
|
+
proprietary-trading firm or a funded-trader program provides or allocates
|
|
167
|
+
to a person or entity to trade, including a challenge, evaluation or
|
|
168
|
+
simulated account, is investment capital whether it is real or simulated.
|
|
153
169
|
|
|
154
170
|
**Commercial Use** means any use of the software that is not a permitted
|
|
155
171
|
purpose, other than distributing copies under the Distribution License
|
package/README.md
CHANGED
|
@@ -19,7 +19,8 @@ Stable releases are on the `latest` dist-tag. A prerelease (for example
|
|
|
19
19
|
- `pineforge_codegen/diagnostics_catalog.json` — the diagnostics catalog (since 1.2.0), also
|
|
20
20
|
importable as `@pineforge/codegen-pyodide/diagnostics_catalog.json`; the repository's
|
|
21
21
|
`docs/PUBLIC_CONTRACT.md` describes it.
|
|
22
|
-
- `LICENSE` — the PineForge Source License
|
|
22
|
+
- `LICENSE` — the PineForge Source License (since 1.2.0), which `package.json` names:
|
|
23
|
+
version 1.1 in release 1.2.0, version 1.2 from release 1.3.0 on.
|
|
23
24
|
|
|
24
25
|
## Publishing (maintainers)
|
|
25
26
|
A `v*` tag push, which the release workflow makes, publishes through npm OIDC Trusted
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pineforge/codegen-pyodide",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -240,6 +240,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
|
|
|
240
240
|
def __init__(self, ctx: AnalyzerContext,
|
|
241
241
|
budget: TimeBudget | None = None) -> None:
|
|
242
242
|
self.ctx = ctx
|
|
243
|
+
self._order_shape_calls = {}
|
|
244
|
+
self._order_shape_host_reads: set[str] = set()
|
|
245
|
+
self._order_shape_constructor: list[str] = []
|
|
243
246
|
self._budget = budget
|
|
244
247
|
self._budget_visit_count = 0
|
|
245
248
|
self._initialise_safe_names(ctx.ast)
|
|
@@ -4490,7 +4493,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
|
|
|
4490
4493
|
self._prepare_lazy_source_clock_sites()
|
|
4491
4494
|
self.block_locals_needing_names = self._block_locals_needing_names()
|
|
4492
4495
|
|
|
4493
|
-
|
|
4496
|
+
from .order_shapes import HostReadLines
|
|
4497
|
+
lines: list[str] = HostReadLines(self._order_shape_host_reads)
|
|
4494
4498
|
|
|
4495
4499
|
# Series<T> ctor-arg suffix from any max_bars_back directive (empty when
|
|
4496
4500
|
# absent, so directive-free output is byte-identical to before).
|
|
@@ -5450,7 +5454,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
|
|
|
5450
5454
|
if self._uses_session_market:
|
|
5451
5455
|
lines.insert(_session_market_member_at, SESSION_MARKET_MEMBER)
|
|
5452
5456
|
lines.insert(_session_market_at, SESSION_MARKET_CPP)
|
|
5457
|
+
self._capabilities_export_at += 2
|
|
5453
5458
|
|
|
5459
|
+
from .capabilities import emit_capabilities_exports
|
|
5460
|
+
exports: list[str] = []
|
|
5461
|
+
emit_capabilities_exports(self, exports)
|
|
5462
|
+
lines[self._capabilities_export_at:self._capabilities_export_at] = exports
|
|
5454
5463
|
cpp = "\n".join(lines)
|
|
5455
5464
|
self._settle_session_reads(cpp)
|
|
5456
5465
|
return cpp
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import math
|
|
5
5
|
|
|
6
|
-
from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, UnaryOp, VarDecl
|
|
6
|
+
from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, Subscript, UnaryOp, VarDecl
|
|
7
7
|
from ..external_requests import CAPABILITY_UNPINNED_ANNOTATION
|
|
8
8
|
from ..limits import iter_ast_nodes
|
|
9
9
|
|
|
@@ -34,6 +34,28 @@ DECLARATION_DEFAULTS = {
|
|
|
34
34
|
"calc_on_every_history_tick": False,
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
MODELED_ORDER_ARGUMENTS = {
|
|
38
|
+
"strategy.entry": frozenset(("id", "direction", "limit", "stop")),
|
|
39
|
+
"strategy.exit": frozenset(("id", "from_entry", "limit", "stop")),
|
|
40
|
+
"strategy.close": frozenset(("id", "immediately")),
|
|
41
|
+
"strategy.close_all": frozenset(("immediately",)),
|
|
42
|
+
}
|
|
43
|
+
READ_ONLY_STRATEGY_CALLS = frozenset(
|
|
44
|
+
f"strategy.{namespace}.{accessor}"
|
|
45
|
+
for namespace in ("opentrades", "closedtrades")
|
|
46
|
+
for accessor in ("entry_id", "entry_price", "entry_time", "entry_bar_index", "size",
|
|
47
|
+
"profit", "profit_percent", "commission", "max_runup", "max_runup_percent",
|
|
48
|
+
"max_drawdown", "max_drawdown_percent", "exit_id", "exit_price",
|
|
49
|
+
"exit_time", "exit_bar_index", "entry_comment", "exit_comment")
|
|
50
|
+
) | frozenset(("strategy.convert_to_account", "strategy.convert_to_symbol"))
|
|
51
|
+
ORDER_SETTINGS = frozenset(("pyramiding", "default_qty_type", "default_qty_value", "initial_capital",
|
|
52
|
+
"slippage", "commission_type", "commission_value", "close_entries_rule",
|
|
53
|
+
"margin_long", "margin_short", "risk_free_rate"))
|
|
54
|
+
PROVEN_BRACKET_SETTINGS = {"default_qty_type": "strategy.fixed", "default_qty_value": 1,
|
|
55
|
+
"commission_type": "strategy.commission.percent", "commission_value": 0}
|
|
56
|
+
PROVEN_SLIPPED_SETTINGS = {"default_qty_type": "strategy.percent_of_equity",
|
|
57
|
+
"default_qty_value": 100, "slippage": 15}
|
|
58
|
+
|
|
37
59
|
|
|
38
60
|
def _expression(node):
|
|
39
61
|
if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)):
|
|
@@ -44,6 +66,8 @@ def _expression(node):
|
|
|
44
66
|
return f"{_expression(node.object)}.{node.member}"
|
|
45
67
|
if isinstance(node, FuncCall):
|
|
46
68
|
return f"{_expression(node.callee)}({','.join(str(_expression(arg)) for arg in node.args)})"
|
|
69
|
+
if isinstance(node, Subscript):
|
|
70
|
+
return f"{_expression(node.object)}[{_expression(node.index)}]"
|
|
47
71
|
return None
|
|
48
72
|
|
|
49
73
|
|
|
@@ -61,13 +85,57 @@ def _literal(node):
|
|
|
61
85
|
return _expression(node), False
|
|
62
86
|
|
|
63
87
|
|
|
64
|
-
def
|
|
88
|
+
def _order_arguments(node):
|
|
89
|
+
name = _expression(node.callee)
|
|
90
|
+
positional = {
|
|
91
|
+
"strategy.entry": ("id", "direction", "qty", "limit", "stop", "oca_name", "oca_type", "comment", "alert_message", "disable_alert"),
|
|
92
|
+
"strategy.exit": ("id", "from_entry", "qty", "qty_percent", "profit", "limit", "loss", "stop", "trail_price", "trail_points", "trail_offset", "oca_name", "comment", "alert_message", "alert_profit", "alert_loss", "alert_trailing", "disable_alert"),
|
|
93
|
+
"strategy.close": ("id", "comment", "qty", "qty_percent", "alert_message", "immediately", "disable_alert"),
|
|
94
|
+
"strategy.close_all": ("comment", "alert_message", "immediately", "disable_alert"),
|
|
95
|
+
}.get(name, ())
|
|
96
|
+
arguments = {positional[index] if index < len(positional) else f"positional_{index}": argument
|
|
97
|
+
for index, argument in enumerate(node.args)}
|
|
98
|
+
if arguments.keys() & node.kwargs.keys():
|
|
99
|
+
arguments["duplicate_argument"] = None
|
|
100
|
+
arguments.update(node.kwargs)
|
|
101
|
+
return arguments
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _order_shape(node, short_ids):
|
|
105
|
+
name = _expression(node.callee)
|
|
106
|
+
arguments = _order_arguments(node)
|
|
107
|
+
if name not in MODELED_ORDER_ARGUMENTS or not set(arguments) <= MODELED_ORDER_ARGUMENTS[name]:
|
|
108
|
+
return name + (" (unproven exit terms)" if name == "strategy.exit" else " (unproven order shape)")
|
|
109
|
+
if name != "strategy.close_all" and not isinstance(arguments.get("id"), StringLiteral):
|
|
110
|
+
return name + " (unproven order id)"
|
|
111
|
+
if name == "strategy.entry" and _expression(arguments.get("direction")) in ("strategy.long", "strategy.short"):
|
|
112
|
+
priced = {key for key in ("limit", "stop") if key in arguments}
|
|
113
|
+
if len(priced) < 2 and not (set(arguments) & {"oca_name", "oca_type"}):
|
|
114
|
+
return "entry:" + (next(iter(priced)) if priced else "market")
|
|
115
|
+
if name == "strategy.exit":
|
|
116
|
+
if (set(arguments) & {"profit", "loss", "trail_price", "trail_points", "trail_offset", "qty", "qty_percent"}):
|
|
117
|
+
return name + " (unproven exit terms)"
|
|
118
|
+
if ("limit" in arguments and "stop" in arguments
|
|
119
|
+
and isinstance(arguments.get("from_entry"), StringLiteral)
|
|
120
|
+
and _expression(arguments["from_entry"]) in short_ids):
|
|
121
|
+
return "exit:short_bracket"
|
|
122
|
+
if name in ("strategy.close", "strategy.close_all"):
|
|
123
|
+
immediate = arguments.get("immediately")
|
|
124
|
+
if immediate is None or isinstance(immediate, BoolLiteral) and not immediate.value:
|
|
125
|
+
if not (set(arguments) & {"qty", "qty_percent"}):
|
|
126
|
+
return "close:market"
|
|
127
|
+
return name + " (unproven order shape)"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def capabilities_documents(emitter) -> tuple[str, str]:
|
|
65
131
|
declarations = dict(DECLARATION_DEFAULTS)
|
|
66
132
|
unresolved = []
|
|
67
133
|
requests = []
|
|
68
134
|
lower_symbols = {}
|
|
69
135
|
intrabar = False
|
|
70
136
|
recorded = False
|
|
137
|
+
order_nodes = []
|
|
138
|
+
order_settings = {}
|
|
71
139
|
for node, _depth in iter_ast_nodes(emitter.ctx.ast):
|
|
72
140
|
if isinstance(node, StrategyDecl):
|
|
73
141
|
arguments = {STRATEGY_PARAMETERS[index] if index < len(STRATEGY_PARAMETERS)
|
|
@@ -75,6 +143,8 @@ def capabilities_document(emitter) -> str:
|
|
|
75
143
|
for index, argument in enumerate(node.args)}
|
|
76
144
|
unresolved.extend(arguments.keys() & node.kwargs.keys())
|
|
77
145
|
arguments.update(node.kwargs)
|
|
146
|
+
order_settings.update({name: _literal(argument)[0] for name, argument in arguments.items()
|
|
147
|
+
if name in ORDER_SETTINGS})
|
|
78
148
|
for name, argument in arguments.items():
|
|
79
149
|
value, valid = _literal(argument)
|
|
80
150
|
default = DECLARATION_DEFAULTS.get(name)
|
|
@@ -92,6 +162,9 @@ def capabilities_document(emitter) -> str:
|
|
|
92
162
|
intrabar |= node.is_varip
|
|
93
163
|
elif isinstance(node, FuncCall):
|
|
94
164
|
name, namespace = emitter._resolve_callee(node.callee)
|
|
165
|
+
qualified = _expression(node.callee) or ""
|
|
166
|
+
if qualified.startswith("strategy.") and qualified not in READ_ONLY_STRATEGY_CALLS:
|
|
167
|
+
order_nodes.append(node)
|
|
95
168
|
if namespace == "request" and name == "security_lower_tf":
|
|
96
169
|
expression = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
|
|
97
170
|
lower_symbols[id(expression)] = node.args[0] if node.args else node.kwargs.get("symbol")
|
|
@@ -118,22 +191,27 @@ def capabilities_document(emitter) -> str:
|
|
|
118
191
|
unresolved.append(f"{_expression(node)} (stream endpoint is not batch-equivalent)")
|
|
119
192
|
|
|
120
193
|
auxiliary = False
|
|
194
|
+
confirmed_requests = []
|
|
195
|
+
lowering_sites = {site["sec_id"]: site for site in emitter._security_eval_info}
|
|
121
196
|
for site in emitter._security_calls:
|
|
197
|
+
lowering = lowering_sites[site["sec_id"]]
|
|
122
198
|
symbol_node = site.get("symbol_node")
|
|
123
199
|
if site.get("is_lower_tf_array"):
|
|
124
200
|
symbol_node = lower_symbols.get(id(site.get("expr_node")), symbol_node)
|
|
125
201
|
if isinstance(symbol_node, FuncCall) and site.get("heikinashi") and symbol_node.args:
|
|
126
202
|
symbol_node = symbol_node.args[0]
|
|
127
203
|
symbol = _expression(symbol_node) if symbol_node is not None else "syminfo.tickerid"
|
|
128
|
-
timeframe =
|
|
204
|
+
timeframe = lowering.get("tf")
|
|
205
|
+
if timeframe is None:
|
|
206
|
+
timeframe = "timeframe.period" if lowering.get("tf_expr") == "script_tf_" else _expression(site.get("tf_node"))
|
|
129
207
|
lookahead = _expression(site.get("lookahead_node")) or "barmerge.lookahead_off"
|
|
130
208
|
gaps = _expression(site.get("gaps_node")) or "barmerge.gaps_off"
|
|
131
209
|
if isinstance(symbol_node, Identifier) and symbol_node.name not in emitter._global_mutable_infos:
|
|
132
210
|
symbol = _expression((getattr(emitter.ctx, "global_expr_map", {}) or {}).get(
|
|
133
211
|
symbol_node.name, symbol_node))
|
|
134
|
-
feed = "
|
|
212
|
+
feed = "auxiliary" if lowering.get("foreign") else "chart"
|
|
135
213
|
auxiliary |= feed == "auxiliary"
|
|
136
|
-
if
|
|
214
|
+
if lowering.get("tf") is None and timeframe != "timeframe.period":
|
|
137
215
|
unresolved.append(f"request.security[{site['sec_id']}].timeframe")
|
|
138
216
|
if lookahead not in ("barmerge.lookahead_off", "barmerge.lookahead_on"):
|
|
139
217
|
unresolved.append(f"request.security[{site['sec_id']}].lookahead")
|
|
@@ -143,9 +221,14 @@ def capabilities_document(emitter) -> str:
|
|
|
143
221
|
"timeframe": timeframe,
|
|
144
222
|
"lookahead": lookahead,
|
|
145
223
|
"gaps": gaps,
|
|
146
|
-
"heikinashi": bool(
|
|
224
|
+
"heikinashi": bool(lowering.get("heikinashi")),
|
|
147
225
|
"feed": feed,
|
|
148
226
|
})
|
|
227
|
+
expression = _expression(site.get("expr_node"))
|
|
228
|
+
if expression in ("close", "close[1]", "ta.sma(close,4)", "ta.ema(close,3)") and "close" in (
|
|
229
|
+
getattr(emitter.ctx, "global_expr_map", {}) or {}):
|
|
230
|
+
unresolved.append("request.security.expression (user-bound close)")
|
|
231
|
+
confirmed_requests.append({**requests[-1], "expression": expression})
|
|
149
232
|
for index, site in enumerate((emitter.ctx.ast.annotations or {}).get(CAPABILITY_UNPINNED_ANNOTATION, ())):
|
|
150
233
|
requests.append({
|
|
151
234
|
"function": site["function"],
|
|
@@ -166,13 +249,46 @@ def capabilities_document(emitter) -> str:
|
|
|
166
249
|
"historical_probe_overrides": False,
|
|
167
250
|
"intrabar_persistence": intrabar,
|
|
168
251
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
252
|
+
short_ids = set()
|
|
253
|
+
conflicting_ids = set()
|
|
254
|
+
dynamic_entry_id = False
|
|
255
|
+
for node in order_nodes:
|
|
256
|
+
if _expression(node.callee) != "strategy.entry":
|
|
257
|
+
continue
|
|
258
|
+
arguments = _order_arguments(node)
|
|
259
|
+
identifier = arguments.get("id")
|
|
260
|
+
if not isinstance(identifier, StringLiteral):
|
|
261
|
+
dynamic_entry_id = True
|
|
262
|
+
elif _expression(arguments.get("direction")) == "strategy.short":
|
|
263
|
+
short_ids.add(identifier.value)
|
|
264
|
+
else:
|
|
265
|
+
conflicting_ids.add(identifier.value)
|
|
266
|
+
if dynamic_entry_id or conflicting_ids:
|
|
267
|
+
short_ids.clear()
|
|
268
|
+
orders = set(_order_shape(node, short_ids - conflicting_ids) for node in order_nodes)
|
|
269
|
+
if declarations["process_orders_on_close"]:
|
|
270
|
+
bracket = orders == {"entry:market", "exit:short_bracket"}
|
|
271
|
+
proven = (not order_settings and not bracket) or (
|
|
272
|
+
bracket and order_settings == PROVEN_BRACKET_SETTINGS) or (
|
|
273
|
+
orders == {"entry:market"} and order_settings == PROVEN_SLIPPED_SETTINGS
|
|
274
|
+
and not confirmed_requests and not intrabar)
|
|
275
|
+
if not proven:
|
|
276
|
+
orders.add("strategy() (unproven POOC sizing, slippage or account settings)")
|
|
277
|
+
confirmed = {"version": 1, "requests": confirmed_requests,
|
|
278
|
+
"orders": sorted(orders),
|
|
279
|
+
"intrabar_persistence": intrabar}
|
|
280
|
+
encode = lambda value: json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
281
|
+
return (encode({"version": 1, "declarations": declarations, "requests": requests,
|
|
282
|
+
"requirements": requirements, "unresolved": sorted(set(unresolved))}), encode(confirmed))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def capabilities_document(emitter) -> str:
|
|
286
|
+
return capabilities_documents(emitter)[0]
|
|
172
287
|
|
|
173
288
|
|
|
174
289
|
def emit_capabilities_exports(emitter, lines: list[str]) -> None:
|
|
175
|
-
|
|
290
|
+
legacy, confirmed = capabilities_documents(emitter)
|
|
291
|
+
literal = json.dumps(legacy)
|
|
176
292
|
lines.extend([
|
|
177
293
|
"#ifdef PF_SETTINGS_API_VERSION",
|
|
178
294
|
"#ifdef PF_CAPABILITIES_API_VERSION",
|
|
@@ -182,5 +298,22 @@ def emit_capabilities_exports(emitter, lines: list[str]) -> None:
|
|
|
182
298
|
" return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
|
|
183
299
|
' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
|
|
184
300
|
f" ::pineforge::checked_settings::receipt({literal}, json, capacity, required);",
|
|
301
|
+
" });", " }",
|
|
302
|
+
" uint32_t strategy_confirmed_bar_api_version(void) { return 1u; }",
|
|
303
|
+
" int strategy_confirmed_bar_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
|
|
304
|
+
" if (required) *required = 0;",
|
|
305
|
+
" return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
|
|
306
|
+
' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
|
|
307
|
+
f" ::pineforge::checked_settings::receipt({json.dumps(confirmed)}, json, capacity, required);",
|
|
185
308
|
" });", " }", "#endif", "#endif",
|
|
186
309
|
])
|
|
310
|
+
from .order_shapes import order_shapes_document
|
|
311
|
+
lines[-2:-2] = [
|
|
312
|
+
" uint32_t strategy_order_shapes_api_version(void) { return 1u; }",
|
|
313
|
+
" int strategy_order_shapes_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
|
|
314
|
+
" if (required) *required = 0;",
|
|
315
|
+
" return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
|
|
316
|
+
' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
|
|
317
|
+
f" ::pineforge::checked_settings::receipt({json.dumps(order_shapes_document(emitter))}, json, capacity, required);",
|
|
318
|
+
" });", " }",
|
|
319
|
+
]
|
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
"""Opt-in settings metadata, validation and exception-contained C exports."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
from ..ast_nodes import Identifier, MemberAccess
|
|
4
6
|
from ..errors import CompileError
|
|
7
|
+
from .tables import ALERT_FREQ_VALUES, NAME_ECHO_STRING_MEMBERS, ORDER_DIRECTION_MAP
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_QUOTED_STRING = r'"(?:[^"\\]|\\.)*"'
|
|
11
|
+
_STRING_LITERAL = re.compile(rf'(?:std::string\({_QUOTED_STRING}\)|{_QUOTED_STRING})')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _string_setting_arg(expr, lowered: str) -> str | None:
|
|
15
|
+
"""Use the body's known string constants, never its inert visual codes.
|
|
16
|
+
|
|
17
|
+
Name-echo strings are accepted only for inventoried currency/format members.
|
|
18
|
+
Unknown constants leave the checked input unsupported; legacy getters
|
|
19
|
+
and setters retain their existing execution semantics.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(expr, MemberAccess):
|
|
22
|
+
if not isinstance(expr.object, Identifier):
|
|
23
|
+
return None
|
|
24
|
+
namespace = expr.object.name
|
|
25
|
+
known = (
|
|
26
|
+
namespace == "alert" and expr.member in ALERT_FREQ_VALUES
|
|
27
|
+
or namespace == "order" and expr.member in ORDER_DIRECTION_MAP
|
|
28
|
+
or namespace == "session" and expr.member in ("regular", "extended")
|
|
29
|
+
or expr.member in NAME_ECHO_STRING_MEMBERS.get(namespace, ())
|
|
30
|
+
)
|
|
31
|
+
if not known:
|
|
32
|
+
return None
|
|
33
|
+
return lowered if _STRING_LITERAL.fullmatch(lowered) else None
|
|
5
34
|
|
|
6
35
|
|
|
7
36
|
def _visit_setting_arg(emitter, expr) -> str:
|
|
@@ -51,7 +80,8 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
|
|
|
51
80
|
key = emitter._input_key_literal(name)
|
|
52
81
|
default = emitter._get_input_default(node)
|
|
53
82
|
getter = emitter._input_getter_for_call(node, func_name, namespace)
|
|
54
|
-
|
|
83
|
+
default_lowered = _visit_setting_arg(emitter, default) if default is not None else "0"
|
|
84
|
+
default_cpp = default_lowered
|
|
55
85
|
default_cpp = emitter._coerce_string_input_default(getter, default_cpp)
|
|
56
86
|
value_type = {
|
|
57
87
|
"get_input_int": "int", "get_input_int64": "int",
|
|
@@ -63,6 +93,11 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
|
|
|
63
93
|
option_values = []
|
|
64
94
|
supported = "true"
|
|
65
95
|
default_serialized = None
|
|
96
|
+
if getter == "get_input_string":
|
|
97
|
+
default_serialized = _string_setting_arg(default, default_lowered)
|
|
98
|
+
if default_serialized is None:
|
|
99
|
+
default_serialized = 'std::string("")'
|
|
100
|
+
supported = "false"
|
|
66
101
|
names, merged = emitter._merged_args(node, func_name, namespace)
|
|
67
102
|
arguments = dict(zip(names or [], merged))
|
|
68
103
|
arguments.update(node.kwargs)
|
|
@@ -103,8 +138,19 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
|
|
|
103
138
|
if declared:
|
|
104
139
|
for option in declared:
|
|
105
140
|
option_cpp = _visit_setting_arg(emitter, option)
|
|
106
|
-
|
|
107
|
-
|
|
141
|
+
if getter == "get_input_string":
|
|
142
|
+
option_cpp = _string_setting_arg(option, option_cpp)
|
|
143
|
+
if option_cpp is None:
|
|
144
|
+
supported = "false"
|
|
145
|
+
else:
|
|
146
|
+
options.append(option_cpp)
|
|
147
|
+
else:
|
|
148
|
+
options.append(f'::pineforge::checked_settings::number({option_cpp})')
|
|
149
|
+
if getter == "get_input_string":
|
|
150
|
+
if arguments.get("options") is not None and declared is None:
|
|
151
|
+
supported = "false"
|
|
152
|
+
if supported == "false":
|
|
153
|
+
options = []
|
|
108
154
|
expression = f'{getter}({key}, {default_cpp})'
|
|
109
155
|
effective = (expression if getter == "get_input_string" else
|
|
110
156
|
f'::pineforge::checked_settings::number({expression})')
|
|
@@ -1281,6 +1281,7 @@ class TopLevelEmitter:
|
|
|
1281
1281
|
lines.append(" pineforge::source::PineStrategyHost::set_strategy_override(overrides);")
|
|
1282
1282
|
lines.append(" }")
|
|
1283
1283
|
from .checked_settings import emit_settings_members
|
|
1284
|
+
self._order_shape_constructor = tuple(ctor_body)
|
|
1284
1285
|
emit_settings_members(self, lines, ctor_body)
|
|
1285
1286
|
|
|
1286
1287
|
if self._uses_recorded_requests():
|
|
@@ -1314,7 +1315,7 @@ class TopLevelEmitter:
|
|
|
1314
1315
|
tf = info.get("tf")
|
|
1315
1316
|
tf_expr = info.get("tf_expr")
|
|
1316
1317
|
if tf:
|
|
1317
|
-
tf_expr =
|
|
1318
|
+
tf_expr = self._security_tf_literal(tf)
|
|
1318
1319
|
elif tf == "" and not tf_expr:
|
|
1319
1320
|
# An empty timeframe string is the chart's.
|
|
1320
1321
|
tf_expr = "input_tf_"
|
|
@@ -1421,7 +1422,7 @@ class TopLevelEmitter:
|
|
|
1421
1422
|
sec_id = info["sec_id"]
|
|
1422
1423
|
symbol = self._security_tf_runtime_expr(info["symbol_node"])
|
|
1423
1424
|
column = self._security_footprint_column(sec_id)
|
|
1424
|
-
column = f', "{column}"' if column else ""
|
|
1425
|
+
column = f', "{self._cpp_string_escape(column)}"' if column else ""
|
|
1425
1426
|
ignore_node = info.get("ignore_invalid_node")
|
|
1426
1427
|
if ignore_node is None:
|
|
1427
1428
|
ignore = "false"
|
|
@@ -1886,7 +1887,7 @@ class TopLevelEmitter:
|
|
|
1886
1887
|
for pragma in pragmas:
|
|
1887
1888
|
cpp_expr = self._visit_expr(pragma.expr_node)
|
|
1888
1889
|
lines.append(
|
|
1889
|
-
f'{inner}trace(std::string("{pragma.name}"), '
|
|
1890
|
+
f'{inner}trace(std::string("{self._cpp_string_escape(pragma.name)}"), '
|
|
1890
1891
|
f"(double)({cpp_expr}));"
|
|
1891
1892
|
)
|
|
1892
1893
|
lines.append(f"{pad}}}")
|
|
@@ -1998,8 +1999,7 @@ class TopLevelEmitter:
|
|
|
1998
1999
|
lines.append(" }")
|
|
1999
2000
|
from .checked_settings import emit_settings_exports
|
|
2000
2001
|
emit_settings_exports(lines)
|
|
2001
|
-
|
|
2002
|
-
emit_capabilities_exports(self, lines)
|
|
2002
|
+
self._capabilities_export_at = len(lines)
|
|
2003
2003
|
if self._declares_bar_magnifier():
|
|
2004
2004
|
# TradingView runs a script that declares use_bar_magnifier = true
|
|
2005
2005
|
# on its bar magnifier; the host reads this export to run it on
|
|
@@ -434,6 +434,12 @@ class NamingHelper:
|
|
|
434
434
|
.replace("\t", "\\t")
|
|
435
435
|
)
|
|
436
436
|
|
|
437
|
+
@staticmethod
|
|
438
|
+
def _cpp_comment_escape(s: str) -> str:
|
|
439
|
+
"""Keep text (a script's string rendered into it) inside the C++
|
|
440
|
+
``/* ... */`` comment it is written in."""
|
|
441
|
+
return s.replace("/*", "/ *").replace("*/", "* /")
|
|
442
|
+
|
|
437
443
|
def _initialise_safe_names(self, ast) -> None:
|
|
438
444
|
"""Reserve all authored spellings before assigning escaped ones.
|
|
439
445
|
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Immutable facts about emitted order parameters and execution context."""
|
|
2
|
+
|
|
3
|
+
from collections import ChainMap, Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from ..ast_nodes import (
|
|
10
|
+
BinOp, BoolLiteral, ForInStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt,
|
|
11
|
+
MemberAccess, MethodDef, NumberLiteral, StringLiteral, SwitchStmt, TupleAssign,
|
|
12
|
+
UnaryOp, VarDecl, WhileStmt,
|
|
13
|
+
)
|
|
14
|
+
from ..limits import iter_ast_nodes, syntax_children
|
|
15
|
+
from .capabilities import READ_ONLY_STRATEGY_CALLS, _expression
|
|
16
|
+
from .host_members import HOST_MEMBER_NAMES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
NUMERIC_PARAMETERS = frozenset((
|
|
20
|
+
"limit", "stop", "qty", "qty_percent", "trail_points", "trail_offset",
|
|
21
|
+
"trail_price", "profit_ticks", "loss_ticks",
|
|
22
|
+
))
|
|
23
|
+
FINITE_BAR_VALUES = frozenset(("open", "high", "low", "close", "hl2", "hlc3", "ohlc4", "hlcc4"))
|
|
24
|
+
ENUM_VALUES = {
|
|
25
|
+
"strategy.oca.none": 0, "strategy.oca.cancel": 1, "strategy.oca.reduce": 2,
|
|
26
|
+
"strategy.fixed": 0, "strategy.percent_of_equity": 1, "strategy.cash": 2,
|
|
27
|
+
}
|
|
28
|
+
SETTING_DEFAULTS = {
|
|
29
|
+
"initial_capital": 10000.0, "default_qty_type": "fixed", "default_qty_value": 1.0,
|
|
30
|
+
"pyramiding": 1, "commission_type": "percent", "commission_value": 0.0,
|
|
31
|
+
"slippage": 0, "margin_long": 100.0, "margin_short": 100.0,
|
|
32
|
+
"close_entries_rule": "FIFO",
|
|
33
|
+
}
|
|
34
|
+
SETTING_ENUMS = {
|
|
35
|
+
"QtyType::FIXED": "fixed", "QtyType::PERCENT_OF_EQUITY": "percent_of_equity",
|
|
36
|
+
"QtyType::CASH": "cash", "CommissionType::PERCENT": "percent",
|
|
37
|
+
"CommissionType::CASH_PER_ORDER": "cash_per_order",
|
|
38
|
+
"CommissionType::CASH_PER_CONTRACT": "cash_per_contract",
|
|
39
|
+
}
|
|
40
|
+
CPP_TOKENS = re.compile(
|
|
41
|
+
r'R"(?P<delimiter>[^ ()\\\t\r\n]{0,16})\(.*?\)(?P=delimiter)"'
|
|
42
|
+
r'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''
|
|
43
|
+
r'|//[^\n]*|/\*.*?(?:\*/|\Z)|[A-Za-z_][A-Za-z_0-9]*',
|
|
44
|
+
re.DOTALL,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_host_reads(cpp: str) -> set[str]:
|
|
49
|
+
"""Cross-check host tokens, excluding C++ strings and comments."""
|
|
50
|
+
return {match.group() for match in CPP_TOKENS.finditer(cpp)
|
|
51
|
+
if match.group() in HOST_MEMBER_NAMES}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HostReadLines(list):
|
|
55
|
+
"""Record scaffold references when each fragment enters the emission buffer."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, reads: set[str]):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.reads = reads
|
|
60
|
+
self.in_comment = False
|
|
61
|
+
|
|
62
|
+
def _record(self, fragment: str) -> None:
|
|
63
|
+
if self.in_comment:
|
|
64
|
+
end = fragment.find("*/")
|
|
65
|
+
if end < 0:
|
|
66
|
+
return
|
|
67
|
+
fragment = fragment[end + 2:]
|
|
68
|
+
self.in_comment = False
|
|
69
|
+
for match in CPP_TOKENS.finditer(fragment):
|
|
70
|
+
token = match.group()
|
|
71
|
+
if token in HOST_MEMBER_NAMES:
|
|
72
|
+
self.reads.add(token)
|
|
73
|
+
if token.startswith("/*") and not token.endswith("*/"):
|
|
74
|
+
self.in_comment = True
|
|
75
|
+
|
|
76
|
+
def append(self, fragment: str) -> None:
|
|
77
|
+
self._record(fragment)
|
|
78
|
+
super().append(fragment)
|
|
79
|
+
|
|
80
|
+
def extend(self, fragments) -> None:
|
|
81
|
+
if fragments is self:
|
|
82
|
+
fragments = list(fragments)
|
|
83
|
+
for fragment in fragments:
|
|
84
|
+
self.append(fragment)
|
|
85
|
+
|
|
86
|
+
def _record_rewrite(self) -> None:
|
|
87
|
+
self.in_comment = False
|
|
88
|
+
for fragment in self:
|
|
89
|
+
self._record(fragment)
|
|
90
|
+
|
|
91
|
+
def insert(self, index: int, fragment: str) -> None:
|
|
92
|
+
super().insert(index, fragment)
|
|
93
|
+
self._record_rewrite()
|
|
94
|
+
|
|
95
|
+
def __setitem__(self, index, fragments) -> None:
|
|
96
|
+
super().__setitem__(index, fragments)
|
|
97
|
+
self._record_rewrite()
|
|
98
|
+
|
|
99
|
+
def __iadd__(self, fragments):
|
|
100
|
+
self.extend(fragments)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __imul__(self, count):
|
|
104
|
+
super().__imul__(count)
|
|
105
|
+
self._record_rewrite()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __delitem__(self, index) -> None:
|
|
109
|
+
super().__delitem__(index)
|
|
110
|
+
self._record_rewrite()
|
|
111
|
+
|
|
112
|
+
def clear(self) -> None:
|
|
113
|
+
super().clear()
|
|
114
|
+
self._record_rewrite()
|
|
115
|
+
|
|
116
|
+
def pop(self, index=-1):
|
|
117
|
+
fragment = super().pop(index)
|
|
118
|
+
self._record_rewrite()
|
|
119
|
+
return fragment
|
|
120
|
+
|
|
121
|
+
def remove(self, fragment) -> None:
|
|
122
|
+
super().remove(fragment)
|
|
123
|
+
self._record_rewrite()
|
|
124
|
+
|
|
125
|
+
def reverse(self) -> None:
|
|
126
|
+
super().reverse()
|
|
127
|
+
self._record_rewrite()
|
|
128
|
+
|
|
129
|
+
def sort(self, *, key=None, reverse=False) -> None:
|
|
130
|
+
try:
|
|
131
|
+
super().sort(key=key, reverse=reverse)
|
|
132
|
+
finally:
|
|
133
|
+
self._record_rewrite()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class LoweredParameter:
|
|
138
|
+
text: str
|
|
139
|
+
node: object
|
|
140
|
+
default: str | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class LoweredCall:
|
|
145
|
+
node: FuncCall
|
|
146
|
+
call: str
|
|
147
|
+
parameters: dict[str, LoweredParameter]
|
|
148
|
+
context: str
|
|
149
|
+
form: str | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def record_order_call(emitter, node, call, parameters, form=None) -> None:
|
|
153
|
+
"""Called only by a branch that lowers the corresponding order command."""
|
|
154
|
+
key = (id(node), getattr(emitter, "_current_instance_name", None))
|
|
155
|
+
emitter._order_shape_calls.setdefault(key, LoweredCall(
|
|
156
|
+
node, call, {name: LoweredParameter(*value) for name, value in parameters.items()},
|
|
157
|
+
"repeatable" if getattr(emitter, "_current_func_body", None) is not None else "straight", form,
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _literal(node, bindings):
|
|
162
|
+
if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral)):
|
|
163
|
+
return node.value
|
|
164
|
+
if isinstance(node, Identifier):
|
|
165
|
+
return bindings.get(node.name)
|
|
166
|
+
if isinstance(node, UnaryOp) and node.op in ("+", "-"):
|
|
167
|
+
value = _literal(node.operand, bindings)
|
|
168
|
+
if type(value) in (int, float):
|
|
169
|
+
return value if node.op == "+" else -value
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _finite(value) -> bool:
|
|
174
|
+
return type(value) in (int, float) and math.isfinite(value)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _never_na(node, bindings) -> bool:
|
|
178
|
+
if _finite(_literal(node, bindings)):
|
|
179
|
+
return True
|
|
180
|
+
if isinstance(node, Identifier):
|
|
181
|
+
return node.name in FINITE_BAR_VALUES and node.name not in bindings
|
|
182
|
+
if isinstance(node, UnaryOp) and node.op in ("+", "-"):
|
|
183
|
+
return _never_na(node.operand, bindings)
|
|
184
|
+
if isinstance(node, BinOp):
|
|
185
|
+
if node.op in ("+", "-"):
|
|
186
|
+
return _never_na(node.left, bindings) and _never_na(node.right, bindings)
|
|
187
|
+
if node.op == "*":
|
|
188
|
+
return ((_finite(_literal(node.left, bindings)) and _never_na(node.right, bindings))
|
|
189
|
+
or (_finite(_literal(node.right, bindings)) and _never_na(node.left, bindings)))
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parameter_class(name, parameter, bindings):
|
|
194
|
+
if parameter.text == parameter.default:
|
|
195
|
+
return "absent"
|
|
196
|
+
value = _literal(parameter.node, bindings)
|
|
197
|
+
if name in ("id", "from_entry"):
|
|
198
|
+
if parameter.node is None and parameter.text == '\"\"':
|
|
199
|
+
value = ""
|
|
200
|
+
if isinstance(value, str):
|
|
201
|
+
return ("global" if not value else "named") if name == "from_entry" else (
|
|
202
|
+
"literal" if value else "empty")
|
|
203
|
+
return "dynamic"
|
|
204
|
+
if name == "direction":
|
|
205
|
+
if parameter.text == "true":
|
|
206
|
+
return "long"
|
|
207
|
+
if parameter.text == "false":
|
|
208
|
+
return "short"
|
|
209
|
+
return "dynamic"
|
|
210
|
+
if name in NUMERIC_PARAMETERS:
|
|
211
|
+
if _finite(value):
|
|
212
|
+
return "literal"
|
|
213
|
+
return "never_na" if _never_na(parameter.node, bindings) else "maybe_na"
|
|
214
|
+
if name in ("comment", "oca_name"):
|
|
215
|
+
if value == "":
|
|
216
|
+
return "absent"
|
|
217
|
+
return "literal" if isinstance(value, str) else "dynamic"
|
|
218
|
+
if name in ("oca_type", "qty_type"):
|
|
219
|
+
if isinstance(parameter.node, MemberAccess):
|
|
220
|
+
value = ENUM_VALUES.get(_expression(parameter.node))
|
|
221
|
+
if _finite(value) and str(int(value)) == parameter.default and value == int(value):
|
|
222
|
+
return "absent"
|
|
223
|
+
return f"literal:{int(value)}" if _finite(value) and value == int(value) else "dynamic"
|
|
224
|
+
if name == "immediately":
|
|
225
|
+
return "literal:true" if value is True else "dynamic"
|
|
226
|
+
raise ValueError(f"unclassified lowered order parameter: {name}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _site_facts(ast, mutable_globals, recorded_nodes):
|
|
230
|
+
"""Source ordering, lexical shadows, and repeatability for authored call sites."""
|
|
231
|
+
bindings = {}
|
|
232
|
+
for node in ast.body:
|
|
233
|
+
if isinstance(node, VarDecl):
|
|
234
|
+
value = _literal(node.value, bindings)
|
|
235
|
+
bindings[node.name] = (value if not node.is_var and not node.is_varip
|
|
236
|
+
and node.name not in mutable_globals else None)
|
|
237
|
+
facts = {}
|
|
238
|
+
|
|
239
|
+
def block(statements, repeatable, visible, names=()):
|
|
240
|
+
local = {name: None for name in names}
|
|
241
|
+
scope = ChainMap(local, visible)
|
|
242
|
+
for statement in statements:
|
|
243
|
+
walk(statement, repeatable, scope)
|
|
244
|
+
if isinstance(statement, VarDecl):
|
|
245
|
+
local[statement.name] = None
|
|
246
|
+
elif isinstance(statement, TupleAssign):
|
|
247
|
+
local.update({name: None for name in statement.names})
|
|
248
|
+
|
|
249
|
+
def walk(node, repeatable, visible):
|
|
250
|
+
if id(node) in recorded_nodes:
|
|
251
|
+
facts[id(node)] = (len(facts), "repeatable" if repeatable else "straight", dict(visible))
|
|
252
|
+
if isinstance(node, (FuncDef, MethodDef)):
|
|
253
|
+
block(node.body, True, visible,
|
|
254
|
+
(name if isinstance(name, str) else name[0] for name in node.params))
|
|
255
|
+
elif isinstance(node, (ForStmt, ForInStmt, WhileStmt)):
|
|
256
|
+
for field in ("start", "end", "step", "iterable", "condition"):
|
|
257
|
+
expression = getattr(node, field, None)
|
|
258
|
+
if expression is not None:
|
|
259
|
+
walk(expression, True, visible)
|
|
260
|
+
names = ([node.var] if node.var else node.vars or []) if isinstance(node, (ForStmt, ForInStmt)) else ()
|
|
261
|
+
block(node.body, True, visible, names)
|
|
262
|
+
elif isinstance(node, IfStmt):
|
|
263
|
+
walk(node.condition, repeatable, visible)
|
|
264
|
+
block(node.body, repeatable, visible)
|
|
265
|
+
block(node.else_body, repeatable, visible)
|
|
266
|
+
elif isinstance(node, SwitchStmt):
|
|
267
|
+
if node.expr is not None:
|
|
268
|
+
walk(node.expr, repeatable, visible)
|
|
269
|
+
for condition, statements in node.cases:
|
|
270
|
+
if condition is not None:
|
|
271
|
+
walk(condition, repeatable, visible)
|
|
272
|
+
block(statements, repeatable, visible)
|
|
273
|
+
block(node.default_body, repeatable, visible)
|
|
274
|
+
else:
|
|
275
|
+
for child in reversed(list(syntax_children(node))):
|
|
276
|
+
walk(child, repeatable, visible)
|
|
277
|
+
|
|
278
|
+
for statement in ast.body:
|
|
279
|
+
walk(statement, False, bindings)
|
|
280
|
+
return facts
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def settings_echo(constructor):
|
|
284
|
+
settings = dict(SETTING_DEFAULTS)
|
|
285
|
+
pooc = False
|
|
286
|
+
for statement in constructor:
|
|
287
|
+
match = re.fullmatch(r" cfg\.(\w+) = (.*);", statement)
|
|
288
|
+
if match is None:
|
|
289
|
+
continue
|
|
290
|
+
name, text = match.groups()
|
|
291
|
+
if name == "process_orders_on_close":
|
|
292
|
+
pooc = text == "true"
|
|
293
|
+
elif name == "close_entries_rule_any":
|
|
294
|
+
settings["close_entries_rule"] = "ANY" if text == "true" else "FIFO"
|
|
295
|
+
elif name in settings:
|
|
296
|
+
if text.startswith("static_cast<int>("):
|
|
297
|
+
settings[name] = SETTING_ENUMS[text[len("static_cast<int>("):-1]]
|
|
298
|
+
else:
|
|
299
|
+
value = float(text)
|
|
300
|
+
settings[name] = (int(value) if name in ("pyramiding", "slippage") else value) if math.isfinite(value) else None
|
|
301
|
+
return settings, pooc
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def order_shapes_document(emitter) -> str:
|
|
305
|
+
recorded_nodes = {id(record.node) for record in emitter._order_shape_calls.values()}
|
|
306
|
+
facts = _site_facts(emitter.ctx.ast, emitter.ctx.global_mutable_infos, recorded_nodes)
|
|
307
|
+
records = sorted(emitter._order_shape_calls.values(), key=lambda record: (
|
|
308
|
+
record.node.loc.line if record.node.loc else 0,
|
|
309
|
+
record.node.loc.col if record.node.loc else 0,
|
|
310
|
+
facts.get(id(record.node), (len(facts),))[0],
|
|
311
|
+
))
|
|
312
|
+
calls = []
|
|
313
|
+
ids = []
|
|
314
|
+
for site, record in enumerate(records):
|
|
315
|
+
_order, context, bindings = facts.get(id(record.node), (site, record.context, {}))
|
|
316
|
+
descriptor = {"site": site, "call": record.call, "context": context}
|
|
317
|
+
if record.form is not None:
|
|
318
|
+
descriptor["form"] = record.form
|
|
319
|
+
descriptor.update({name: parameter_class(name, parameter, bindings)
|
|
320
|
+
for name, parameter in record.parameters.items()})
|
|
321
|
+
calls.append(descriptor)
|
|
322
|
+
ids.append({name: _literal(parameter.node, bindings) if parameter.node is not None else ""
|
|
323
|
+
for name, parameter in record.parameters.items() if name in ("id", "from_entry")})
|
|
324
|
+
entries = [(site, ids[site].get("id"), call.get("direction"))
|
|
325
|
+
for site, call in enumerate(calls) if call["call"] in ("entry", "order")
|
|
326
|
+
and call["id"] in ("literal", "empty")]
|
|
327
|
+
long_ids = {name for _site, name, direction in entries if direction == "long"}
|
|
328
|
+
short_ids = {name for _site, name, direction in entries if direction == "short"}
|
|
329
|
+
for site, call in enumerate(calls):
|
|
330
|
+
if call["call"] not in ("exit", "close", "cancel"):
|
|
331
|
+
continue
|
|
332
|
+
target = ids[site].get("from_entry" if call["call"] == "exit" else "id")
|
|
333
|
+
global_target = target == "" and call["call"] in ("exit", "close")
|
|
334
|
+
matching = [(entry_site, direction) for entry_site, name, direction in entries
|
|
335
|
+
if global_target or target is not None and target == name]
|
|
336
|
+
directions = {direction for _entry_site, direction in matching}
|
|
337
|
+
call["target"] = ("both" if "dynamic" in directions or directions >= {"long", "short"}
|
|
338
|
+
else "long" if "long" in directions else "short" if "short" in directions
|
|
339
|
+
else "dangling")
|
|
340
|
+
if call["call"] == "exit":
|
|
341
|
+
before = any(entry_site > site for entry_site, _direction in matching)
|
|
342
|
+
after = any(entry_site < site for entry_site, _direction in matching)
|
|
343
|
+
call["order"] = "mixed" if before and after or not matching else "before" if before else "after"
|
|
344
|
+
counts = Counter(name for _site, name, _direction in entries)
|
|
345
|
+
settings, pooc = settings_echo(emitter._order_shape_constructor)
|
|
346
|
+
unmodeled = {_expression(node.callee) for node, _depth in iter_ast_nodes(emitter.ctx.ast)
|
|
347
|
+
if isinstance(node, FuncCall) and (_expression(node.callee) or "").startswith("strategy.")
|
|
348
|
+
and _expression(node.callee) not in READ_ONLY_STRATEGY_CALLS
|
|
349
|
+
and id(node) not in recorded_nodes}
|
|
350
|
+
return json.dumps({
|
|
351
|
+
"version": 1, "process_orders_on_close": pooc, "calls": calls,
|
|
352
|
+
"entry_ids": {"long": len(long_ids), "short": len(short_ids),
|
|
353
|
+
"shared": len(long_ids & short_ids), "multi_site": sum(count > 1 for count in counts.values())},
|
|
354
|
+
"host_reads": sorted(emitter._order_shape_host_reads), "settings": settings,
|
|
355
|
+
"unmodeled": sorted(unmodeled),
|
|
356
|
+
}, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
@@ -194,13 +194,19 @@ class SecurityEmitter:
|
|
|
194
194
|
chart timeframe — its evaluator result is never read. Any other
|
|
195
195
|
timeframe registration cannot compute is refused: it used to register
|
|
196
196
|
the chart timeframe, silently.
|
|
197
|
+
|
|
198
|
+
Every timeframe string resolved from the script -- the literal, a
|
|
199
|
+
constant, a helper parameter's argument, a ternary or switch arm --
|
|
200
|
+
is a Pine timeframe (``_refuse_invalid_security_tf``).
|
|
197
201
|
"""
|
|
198
202
|
if isinstance(tf_node, StringLiteral):
|
|
203
|
+
self._refuse_invalid_security_tf(tf_node.value, tf_node)
|
|
199
204
|
return tf_node.value, None
|
|
200
205
|
if isinstance(tf_node, SwitchStmt):
|
|
201
206
|
# Keep diagnostics from the registration-time switch renderer
|
|
202
207
|
# visible; the broad expression fallback below intentionally
|
|
203
208
|
# catches ordinary unresolved expressions.
|
|
209
|
+
self._check_security_tf_arms(tf_node, set())
|
|
204
210
|
return None, self._security_tf_runtime_expr(tf_node)
|
|
205
211
|
if isinstance(tf_node, Identifier):
|
|
206
212
|
name = tf_node.name
|
|
@@ -209,6 +215,7 @@ class SecurityEmitter:
|
|
|
209
215
|
if (name in self._known_vars and name not in self._input_backed_vars
|
|
210
216
|
and not self._known_var_is_lexically_shadowed(name)
|
|
211
217
|
and isinstance(self._known_vars[name], str)):
|
|
218
|
+
self._refuse_invalid_security_tf(self._known_vars[name], tf_node)
|
|
212
219
|
return self._known_vars[name], None
|
|
213
220
|
if (name in self._input_backed_vars
|
|
214
221
|
and name in self._input_var_to_call
|
|
@@ -216,6 +223,7 @@ class SecurityEmitter:
|
|
|
216
223
|
return None, self._visit_expr(self._input_var_to_call[name])
|
|
217
224
|
global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
|
|
218
225
|
if name in global_expr_map:
|
|
226
|
+
self._check_security_tf_arms(global_expr_map[name], {name})
|
|
219
227
|
expanded = self._security_tf_runtime_expr(
|
|
220
228
|
global_expr_map[name], resolving={name}
|
|
221
229
|
)
|
|
@@ -238,6 +246,7 @@ class SecurityEmitter:
|
|
|
238
246
|
self._security_tf_unresolved(tf_node, f"timeframe '{name}'")
|
|
239
247
|
# any other expression — visit if it resolves at class scope
|
|
240
248
|
try:
|
|
249
|
+
self._check_security_tf_arms(tf_node, set())
|
|
241
250
|
expanded = self._security_tf_runtime_expr(tf_node)
|
|
242
251
|
return None, expanded if expanded is not None else self._visit_expr(tf_node)
|
|
243
252
|
except CompileError:
|
|
@@ -245,6 +254,45 @@ class SecurityEmitter:
|
|
|
245
254
|
except Exception:
|
|
246
255
|
self._security_tf_unresolved(tf_node, "timeframe expression")
|
|
247
256
|
|
|
257
|
+
def _refuse_invalid_security_tf(self, value: str, node) -> None:
|
|
258
|
+
"""Refuse a timeframe string resolved from the script that is not a
|
|
259
|
+
Pine timeframe, with the support checker's diagnostic for the same
|
|
260
|
+
string written in the call (``invalid_tf_literal``): it reaches the
|
|
261
|
+
C++ registration. An empty string registers the chart's timeframe."""
|
|
262
|
+
if not value:
|
|
263
|
+
return
|
|
264
|
+
from ..support_checker import invalid_tf_literal
|
|
265
|
+
fn_label = ("request.security_lower_tf" if getattr(self, "_security_tf_lower", False)
|
|
266
|
+
else "request.security")
|
|
267
|
+
refusal = invalid_tf_literal(value, fn_label)
|
|
268
|
+
if refusal is not None:
|
|
269
|
+
message, hint = refusal
|
|
270
|
+
self._codegen_error(node, message, hint=hint)
|
|
271
|
+
|
|
272
|
+
def _check_security_tf_arms(self, node, resolving: set[str]) -> None:
|
|
273
|
+
"""Refuse an invalid timeframe string a registration-time timeframe
|
|
274
|
+
expression can select: the expression itself, a ternary or switch
|
|
275
|
+
arm, read through the constants and globals it names."""
|
|
276
|
+
def check(value) -> None:
|
|
277
|
+
if isinstance(value, StringLiteral):
|
|
278
|
+
self._refuse_invalid_security_tf(value.value, value)
|
|
279
|
+
elif isinstance(value, Ternary):
|
|
280
|
+
check(value.true_val)
|
|
281
|
+
check(value.false_val)
|
|
282
|
+
elif isinstance(value, SwitchStmt):
|
|
283
|
+
arms = [body for _case, body in value.cases]
|
|
284
|
+
for body in arms + ([value.default_body] if value.default_body else []):
|
|
285
|
+
if len(body) == 1 and isinstance(body[0], ExprStmt):
|
|
286
|
+
check(self._substitute_tf_input_reads(body[0].expr, resolving))
|
|
287
|
+
|
|
288
|
+
check(node if isinstance(node, SwitchStmt)
|
|
289
|
+
else self._substitute_tf_input_reads(node, resolving))
|
|
290
|
+
|
|
291
|
+
def _security_tf_literal(self, tf: str) -> str:
|
|
292
|
+
"""A timeframe string as the C++ string literal registration and the
|
|
293
|
+
evaluator's ``timeframe.*`` reads spell it."""
|
|
294
|
+
return f'"{self._cpp_string_escape(tf)}"'
|
|
295
|
+
|
|
248
296
|
def _security_tf_unresolved(self, tf_node, what: str) -> None:
|
|
249
297
|
"""Refuse a request.security timeframe registration cannot compute."""
|
|
250
298
|
self._codegen_error(
|
|
@@ -1981,8 +2029,9 @@ class SecurityEmitter:
|
|
|
1981
2029
|
parts = request.annotations[RECORDED_KEY_ANNOTATION]
|
|
1982
2030
|
tail = (f"|{parts['field']}|{parts['period']}|gaps_{parts['gaps']}"
|
|
1983
2031
|
f"|lookahead_{parts['lookahead']}")
|
|
1984
|
-
|
|
1985
|
-
|
|
2032
|
+
fn = self._cpp_string_escape(parts["fn"])
|
|
2033
|
+
return (f'(std::string("{fn}|") + {self._visit_expr(request.args[0])} + '
|
|
2034
|
+
f'std::string("{self._cpp_string_escape(tail)}"))')
|
|
1986
2035
|
|
|
1987
2036
|
def _recorded_sites(self) -> dict[int, int]:
|
|
1988
2037
|
"""Each recorded request's index N: ``_pf_recorded`` sets its
|
|
@@ -2709,7 +2758,7 @@ class SecurityEmitter:
|
|
|
2709
2758
|
"""C++ expression for the timeframe of a request.security evaluator."""
|
|
2710
2759
|
info = self._security_eval_info[sec_id]
|
|
2711
2760
|
if info.get("tf"):
|
|
2712
|
-
return
|
|
2761
|
+
return self._security_tf_literal(info["tf"])
|
|
2713
2762
|
if info.get("tf_expr"):
|
|
2714
2763
|
return info["tf_expr"]
|
|
2715
2764
|
return "input_tf_"
|
|
@@ -2775,7 +2824,7 @@ class SecurityEmitter:
|
|
|
2775
2824
|
if series_name in self._security_string_series
|
|
2776
2825
|
else "_security_helper_series_"
|
|
2777
2826
|
)
|
|
2778
|
-
return f'{store}["{series_name}"]'
|
|
2827
|
+
return f'{store}["{self._cpp_string_escape(series_name)}"]'
|
|
2779
2828
|
|
|
2780
2829
|
def _security_helper_var_state_type(self, stmt: VarDecl) -> str:
|
|
2781
2830
|
"""The type family of a helper ``var`` whose declaration reads
|
|
@@ -6377,7 +6426,7 @@ class SecurityEmitter:
|
|
|
6377
6426
|
if column is not None and self._security_foreign(sec_id):
|
|
6378
6427
|
# request.footprint(...) of another symbol: the delta its feed
|
|
6379
6428
|
# records for the requested bar (the value its delta() reads).
|
|
6380
|
-
return f'_pf_symbol_column({sec_id}, "{column}")'
|
|
6429
|
+
return f'_pf_symbol_column({sec_id}, "{self._cpp_string_escape(column)}")'
|
|
6381
6430
|
|
|
6382
6431
|
if resolving is None:
|
|
6383
6432
|
resolving = set()
|
|
@@ -545,6 +545,18 @@ ORDER_DIRECTION_MAP = {
|
|
|
545
545
|
"descending": 'std::string("descending")',
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
NAME_ECHO_STRING_MEMBERS = {
|
|
549
|
+
"currency": frozenset({
|
|
550
|
+
"AED", "ARS", "AUD", "BDT", "BHD", "BRL", "BTC", "CAD", "CHF", "CLP",
|
|
551
|
+
"CNY", "COP", "CZK", "DKK", "EGP", "ETH", "EUR", "GBP", "HKD", "HUF",
|
|
552
|
+
"IDR", "ILS", "INR", "ISK", "JPY", "KES", "KRW", "KWD", "LKR", "MAD",
|
|
553
|
+
"MXN", "MYR", "NGN", "NOK", "NONE", "NZD", "PEN", "PHP", "PKR", "PLN",
|
|
554
|
+
"QAR", "RON", "RSD", "RUB", "SAR", "SEK", "SGD", "THB", "TND", "TRY",
|
|
555
|
+
"TWD", "USD", "USDT", "VES", "VND", "ZAR",
|
|
556
|
+
}),
|
|
557
|
+
"format": frozenset({"inherit", "price", "volume", "percent", "mintick"}),
|
|
558
|
+
}
|
|
559
|
+
|
|
548
560
|
|
|
549
561
|
# ---------------------------------------------------------------------------
|
|
550
562
|
# Array / Map / Matrix method dispatch
|
|
@@ -432,6 +432,7 @@ class CallVisitor:
|
|
|
432
432
|
|
|
433
433
|
close = "true" if func_name == "time_close" else "false"
|
|
434
434
|
empty = 'std::string("")'
|
|
435
|
+
self._order_shape_host_reads.add("pine_time_offset")
|
|
435
436
|
return (
|
|
436
437
|
f"pine_time_offset(current_bar_.timestamp, {offset('bars_back')}, "
|
|
437
438
|
f"{text('timeframe', 'script_tf_')}, {text('session', empty)}, "
|
|
@@ -3166,6 +3167,7 @@ class CallVisitor:
|
|
|
3166
3167
|
return f"{token}ULL"
|
|
3167
3168
|
|
|
3168
3169
|
def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str:
|
|
3170
|
+
from .order_shapes import record_order_call
|
|
3169
3171
|
if func_name in ("convert_to_account", "convert_to_symbol"):
|
|
3170
3172
|
p = self._resolve_func_args(node, f"strategy.{func_name}")
|
|
3171
3173
|
v = self._visit_expr(p.get("value")) if p.get("value") is not None else "0.0"
|
|
@@ -3200,6 +3202,16 @@ class CallVisitor:
|
|
|
3200
3202
|
if stop is not None or limit is not None or qty is not None or oca_name is not None or oca_type is not None or qty_type is not None:
|
|
3201
3203
|
limit_val = self._visit_expr(limit) if limit else "na<double>()"
|
|
3202
3204
|
stop_val = self._visit_expr(stop) if stop else "na<double>()"
|
|
3205
|
+
record_order_call(self, node, "entry", {
|
|
3206
|
+
"id": (entry_id, p.get("id")), "direction": (direction, direction_node),
|
|
3207
|
+
"limit": (limit_val, limit, "na<double>()"),
|
|
3208
|
+
"stop": (stop_val, stop, "na<double>()"),
|
|
3209
|
+
"qty": (qty_val, qty, "na<double>()"),
|
|
3210
|
+
"comment": (comment_val, comment, '""'),
|
|
3211
|
+
"oca_name": (oca_name_val, oca_name, '""'),
|
|
3212
|
+
"oca_type": (oca_type_val, oca_type, "0"),
|
|
3213
|
+
"qty_type": (qty_type_val, qty_type, "-1"),
|
|
3214
|
+
})
|
|
3203
3215
|
# pineforge-engine v0.2 dropped the vestigial `market_price`
|
|
3204
3216
|
# third positional from `BacktestEngine::strategy_entry`
|
|
3205
3217
|
# (the runtime never read it; fill price always came from
|
|
@@ -3207,6 +3219,15 @@ class CallVisitor:
|
|
|
3207
3219
|
# matches the new signature: (id, direction, limit, stop,
|
|
3208
3220
|
# qty, comment, oca_name, oca_type, qty_type).
|
|
3209
3221
|
return f"strategy_entry({entry_id}, {direction}, {limit_val}, {stop_val}, {qty_val}, {comment_val}, {oca_name_val}, {oca_type_val}, {qty_type_val})"
|
|
3222
|
+
record_order_call(self, node, "entry", {
|
|
3223
|
+
"id": (entry_id, p.get("id")), "direction": (direction, direction_node),
|
|
3224
|
+
"limit": ("na<double>()", None, "na<double>()"),
|
|
3225
|
+
"stop": ("na<double>()", None, "na<double>()"),
|
|
3226
|
+
"qty": ("na<double>()", None, "na<double>()"),
|
|
3227
|
+
"comment": (comment_val, comment, '""'),
|
|
3228
|
+
"oca_name": ('""', None, '""'), "oca_type": ("0", None, "0"),
|
|
3229
|
+
"qty_type": ("-1", None, "-1"),
|
|
3230
|
+
})
|
|
3210
3231
|
return f"strategy_entry({entry_id}, {direction}, na<double>(), na<double>(), na<double>(), {comment_val})"
|
|
3211
3232
|
|
|
3212
3233
|
if func_name == "close":
|
|
@@ -3223,6 +3244,12 @@ class CallVisitor:
|
|
|
3223
3244
|
if immediately_node is not None else "false"
|
|
3224
3245
|
)
|
|
3225
3246
|
callsite = self._strategy_close_callsite_token(node)
|
|
3247
|
+
record_order_call(self, node, "close", {
|
|
3248
|
+
"id": (close_id, p.get("id")), "comment": (comment, p.get("comment"), '""'),
|
|
3249
|
+
"qty": (qty, p.get("qty"), "na<double>()"),
|
|
3250
|
+
"qty_percent": (qty_pct, p.get("qty_percent"), "na<double>()"),
|
|
3251
|
+
"immediately": (immediately, immediately_node, "false"),
|
|
3252
|
+
})
|
|
3226
3253
|
return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately}, {callsite})"
|
|
3227
3254
|
|
|
3228
3255
|
if func_name == "close_all":
|
|
@@ -3235,6 +3262,12 @@ class CallVisitor:
|
|
|
3235
3262
|
)
|
|
3236
3263
|
if immediately_node is not None else "false"
|
|
3237
3264
|
)
|
|
3265
|
+
record_order_call(self, node, "close_all", {
|
|
3266
|
+
"comment": (comment, p.get("comment"), '""'),
|
|
3267
|
+
"qty": ("na<double>()", None, "na<double>()"),
|
|
3268
|
+
"qty_percent": ("na<double>()", None, "na<double>()"),
|
|
3269
|
+
"immediately": (immediately, immediately_node, "false"),
|
|
3270
|
+
})
|
|
3238
3271
|
# The engine's ID-less strategy_close path closes the complete
|
|
3239
3272
|
# position. Reuse it so close_all preserves the Pine order comment
|
|
3240
3273
|
# and same-tick fill flag instead of silently discarding both.
|
|
@@ -3278,18 +3311,38 @@ class CallVisitor:
|
|
|
3278
3311
|
if loss_n and not stop_n:
|
|
3279
3312
|
loss_ticks = self._visit_expr(loss_n)
|
|
3280
3313
|
|
|
3314
|
+
record_order_call(self, node, "exit", {
|
|
3315
|
+
"id": (exit_id, p.get("id")), "from_entry": (from_id, p.get("from_entry")),
|
|
3316
|
+
"limit": (limit_val, limit_n, "na<double>()"),
|
|
3317
|
+
"stop": (stop_val, stop_n, "na<double>()"),
|
|
3318
|
+
"trail_points": (trail_pts, trail_pts_n, "na<double>()"),
|
|
3319
|
+
"trail_offset": (trail_off, trail_off_n, "na<double>()"),
|
|
3320
|
+
"trail_price": (trail_pr, trail_pr_n, "na<double>()"),
|
|
3321
|
+
"qty_percent": (qty_pct, qty_pct_n, "100.0"),
|
|
3322
|
+
"comment": (comment, comment_n, '""'),
|
|
3323
|
+
"qty": (qty_val, qty_n, "na<double>()"),
|
|
3324
|
+
"oca_name": (oca_val, oca_name_n, '""'),
|
|
3325
|
+
"profit_ticks": (profit_ticks, profit_n if not limit_n else None, "na<double>()"),
|
|
3326
|
+
"loss_ticks": (loss_ticks, loss_n if not stop_n else None, "na<double>()"),
|
|
3327
|
+
}, form="levels")
|
|
3281
3328
|
return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
|
|
3282
3329
|
f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
|
|
3283
3330
|
f"{qty_val}, {oca_val}, {profit_ticks}, {loss_ticks})")
|
|
3284
3331
|
comment = self._visit_expr(comment_n) if comment_n is not None else '""'
|
|
3332
|
+
record_order_call(self, node, "exit", {
|
|
3333
|
+
"id": (exit_id, p.get("id")), "from_entry": (from_id, p.get("from_entry")),
|
|
3334
|
+
"comment": (comment, comment_n, '""'),
|
|
3335
|
+
}, form="cancel_bracket")
|
|
3285
3336
|
return f"strategy_exit_cancel_bracket({exit_id}, {from_id}, {comment})"
|
|
3286
3337
|
|
|
3287
3338
|
if func_name == "cancel":
|
|
3288
3339
|
p = self._resolve_func_args(node, "strategy.close") # same shape: id first
|
|
3289
3340
|
cancel_id = self._visit_expr(p.get("id")) if "id" in p else '""'
|
|
3341
|
+
record_order_call(self, node, "cancel", {"id": (cancel_id, p.get("id"))})
|
|
3290
3342
|
return f"strategy_cancel({cancel_id})"
|
|
3291
3343
|
|
|
3292
3344
|
if func_name == "cancel_all":
|
|
3345
|
+
record_order_call(self, node, "cancel_all", {})
|
|
3293
3346
|
return "strategy_cancel_all()"
|
|
3294
3347
|
|
|
3295
3348
|
if func_name == "order":
|
|
@@ -3307,6 +3360,14 @@ class CallVisitor:
|
|
|
3307
3360
|
stop_arg = self._visit_expr(p.get("stop")) if "stop" in p else "na<double>()"
|
|
3308
3361
|
oca_name = self._visit_expr(p.get("oca_name")) if "oca_name" in p else '""'
|
|
3309
3362
|
oca_type = self._visit_expr(p.get("oca_type")) if "oca_type" in p else "0"
|
|
3363
|
+
record_order_call(self, node, "order", {
|
|
3364
|
+
"id": (order_id, p.get("id")), "direction": (direction, direction_node),
|
|
3365
|
+
"qty": (qty, p.get("qty"), "0"),
|
|
3366
|
+
"limit": (limit_arg, p.get("limit"), "na<double>()"),
|
|
3367
|
+
"stop": (stop_arg, p.get("stop"), "na<double>()"),
|
|
3368
|
+
"oca_name": (oca_name, p.get("oca_name"), '""'),
|
|
3369
|
+
"oca_type": (oca_type, p.get("oca_type"), "0"),
|
|
3370
|
+
})
|
|
3310
3371
|
return f"strategy_order({order_id}, {direction}, {qty}, {limit_arg}, {stop_arg}, {oca_name}, {oca_type})"
|
|
3311
3372
|
|
|
3312
3373
|
if func_name == "risk":
|
|
@@ -952,6 +952,7 @@ class ExprVisitor:
|
|
|
952
952
|
if node.member == "isfirst":
|
|
953
953
|
return "(bar_index_ == 0)"
|
|
954
954
|
if node.member == "islast":
|
|
955
|
+
self._order_shape_host_reads.add("barstate_islast_")
|
|
955
956
|
return "barstate_islast_"
|
|
956
957
|
if node.member == "isnew":
|
|
957
958
|
return "is_first_tick()"
|
|
@@ -962,6 +963,7 @@ class ExprVisitor:
|
|
|
962
963
|
if node.member == "isrealtime":
|
|
963
964
|
return "false"
|
|
964
965
|
if node.member == "islastconfirmedhistory":
|
|
966
|
+
self._order_shape_host_reads.add("barstate_islast_")
|
|
965
967
|
return "barstate_islast_"
|
|
966
968
|
return "false"
|
|
967
969
|
if ns in ("backadjustment", "settlement_as_close"):
|
|
@@ -1046,12 +1048,16 @@ class ExprVisitor:
|
|
|
1046
1048
|
return ismarket
|
|
1047
1049
|
return f"(!{ismarket} && {predicate})"
|
|
1048
1050
|
if node.member == "isfirstbar":
|
|
1051
|
+
self._order_shape_host_reads.add("session_isfirstbar_")
|
|
1049
1052
|
return "session_isfirstbar_"
|
|
1050
1053
|
if node.member == "islastbar":
|
|
1054
|
+
self._order_shape_host_reads.add("session_islastbar_")
|
|
1051
1055
|
return "session_islastbar_"
|
|
1052
1056
|
if node.member == "isfirstbar_regular":
|
|
1057
|
+
self._order_shape_host_reads.add("session_isfirstbar_regular_")
|
|
1053
1058
|
return "session_isfirstbar_regular_"
|
|
1054
1059
|
if node.member == "islastbar_regular":
|
|
1060
|
+
self._order_shape_host_reads.add("session_islastbar_regular_")
|
|
1055
1061
|
return "session_islastbar_regular_"
|
|
1056
1062
|
return "false"
|
|
1057
1063
|
if ns == "syminfo":
|
|
@@ -1083,7 +1089,7 @@ class ExprVisitor:
|
|
|
1083
1089
|
if ns in SKIP_NAMESPACES:
|
|
1084
1090
|
return "0"
|
|
1085
1091
|
if ns == "currency":
|
|
1086
|
-
return f'std::string("{node.member}")'
|
|
1092
|
+
return f'std::string("{self._cpp_string_escape(node.member)}")'
|
|
1087
1093
|
if ns == "order":
|
|
1088
1094
|
# order.ascending / order.descending. Unknown member -> "ascending".
|
|
1089
1095
|
return ORDER_DIRECTION_MAP.get(node.member, 'std::string("ascending")')
|
|
@@ -1163,7 +1169,7 @@ class ExprVisitor:
|
|
|
1163
1169
|
return f"{safe}.{node.member}"
|
|
1164
1170
|
if name not in self.ctx.series_vars:
|
|
1165
1171
|
# Unknown identifier — likely an enum value
|
|
1166
|
-
return f'std::string("{node.member}")'
|
|
1172
|
+
return f'std::string("{self._cpp_string_escape(node.member)}")'
|
|
1167
1173
|
return f"{obj}.{node.member}"
|
|
1168
1174
|
|
|
1169
1175
|
def _operand_na_kind(self, node, cpp_type: str) -> str | None:
|
|
@@ -1100,10 +1100,8 @@ class StmtVisitor:
|
|
|
1100
1100
|
# the backtest runtime. See: pineforge-codegen issue #10.
|
|
1101
1101
|
if self._is_omitted_udt_field(node.target):
|
|
1102
1102
|
recv = self._visit_expr(node.target.object)
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
f"{recv}.{node.target.member} {node.op} ... */"
|
|
1106
|
-
)
|
|
1103
|
+
omitted = self._cpp_comment_escape(f"{recv}.{node.target.member} {node.op}")
|
|
1104
|
+
lines.append(f"{pad}/* drawing field assignment omitted: {omitted} ... */")
|
|
1107
1105
|
return
|
|
1108
1106
|
# General expression target (e.g., member access)
|
|
1109
1107
|
target_cpp = self._visit_mutable_expr(node.target)
|
|
@@ -2843,8 +2843,9 @@ class SupportChecker:
|
|
|
2843
2843
|
# Bare suffix shorthand — Pine treats "D"/"W"/"M"/"S" as 1<suffix>.
|
|
2844
2844
|
# Accept the same shorthand here.
|
|
2845
2845
|
return None
|
|
2846
|
-
if not digits.
|
|
2847
|
-
# catches "1.5", "-15", "abc",
|
|
2846
|
+
if not digits.isdecimal():
|
|
2847
|
+
# catches "1.5", "-15", "abc", and digits int() does not read
|
|
2848
|
+
# ("²"), etc.
|
|
2848
2849
|
return f"non-integer timeframe magnitude '{digits}'"
|
|
2849
2850
|
n = int(digits)
|
|
2850
2851
|
if n <= 0:
|
|
@@ -2859,15 +2860,11 @@ class SupportChecker:
|
|
|
2859
2860
|
return
|
|
2860
2861
|
if not isinstance(tf_node, StringLiteral):
|
|
2861
2862
|
return
|
|
2862
|
-
|
|
2863
|
-
if
|
|
2863
|
+
refusal = invalid_tf_literal(tf_node.value, fn_label)
|
|
2864
|
+
if refusal is None:
|
|
2864
2865
|
return
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
f"{fn_label}: invalid timeframe literal '{tf_node.value}'. "
|
|
2868
|
-
"Expected Pine TF format like '1', '15', '1H', '1D', '15S'.",
|
|
2869
|
-
hint=err,
|
|
2870
|
-
)
|
|
2866
|
+
message, hint = refusal
|
|
2867
|
+
self._err(tf_node, message, hint=hint)
|
|
2871
2868
|
|
|
2872
2869
|
def _check_request_security_lower_tf_symbol(self, node: FuncCall) -> None:
|
|
2873
2870
|
"""``request.security_lower_tf`` reads the chart's intrabars: the
|
|
@@ -2912,6 +2909,18 @@ class SupportChecker:
|
|
|
2912
2909
|
self._check_tf_literal(tf_node, "request.security_lower_tf")
|
|
2913
2910
|
|
|
2914
2911
|
|
|
2912
|
+
def invalid_tf_literal(tf_str: str, fn_label: str) -> tuple[str, str] | None:
|
|
2913
|
+
"""``(message, hint)`` refusing ``tf_str`` as the timeframe of
|
|
2914
|
+
``fn_label`` (``request.security`` / ``request.security_lower_tf``), or
|
|
2915
|
+
None when it is a Pine timeframe. The codegen refuses a timeframe string
|
|
2916
|
+
it resolves from a constant or a helper parameter with the same text."""
|
|
2917
|
+
err = SupportChecker._validate_pine_tf_literal(tf_str)
|
|
2918
|
+
if err is None:
|
|
2919
|
+
return None
|
|
2920
|
+
return (f"{fn_label}: invalid timeframe literal '{tf_str}'. "
|
|
2921
|
+
"Expected Pine TF format like '1', '15', '1H', '1D', '15S'.", err)
|
|
2922
|
+
|
|
2923
|
+
|
|
2915
2924
|
# ---------------------------------------------------------------------------
|
|
2916
2925
|
# Convenience entry point
|
|
2917
2926
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
package/release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"codegen": "1.
|
|
2
|
+
"codegen": "1.3.0",
|
|
3
3
|
"pyodide": "314.0.0",
|
|
4
4
|
"python": "3.14.0",
|
|
5
5
|
"emscripten": "emscripten_5_0_3",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "b058ef7c8ded42e7a80ccf551e62985e052d1e306aebf615ab9937544f060839"
|
|
7
7
|
}
|
package/tables.json
CHANGED
|
Binary file
|