pineforge-codegen 0.6.5__tar.gz → 0.7.0__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.
Files changed (90) hide show
  1. pineforge_codegen-0.7.0/LEGAL.md +32 -0
  2. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/PKG-INFO +1 -1
  3. pineforge_codegen-0.7.0/VERSION +1 -0
  4. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/base.py +5 -2
  5. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/types.py +11 -0
  6. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/helpers_syminfo.py +9 -11
  7. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/tables.py +81 -11
  8. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/types.py +19 -0
  9. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/visit_call.py +160 -78
  10. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/visit_expr.py +31 -3
  11. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/visit_stmt.py +45 -8
  12. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/support_checker.py +123 -11
  13. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/golden/matrix_eigen_pca.cpp +2 -9
  14. pineforge_codegen-0.7.0/tests/test_codegen_audit_fixes.py +288 -0
  15. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_signatures.py +2 -1
  16. pineforge_codegen-0.7.0/tests/test_support_checker_const_namespaces.py +141 -0
  17. pineforge_codegen-0.7.0/tests/test_support_checker_syminfo_gap_fields.py +71 -0
  18. pineforge_codegen-0.6.5/VERSION +0 -1
  19. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/.github/dependabot.yml +0 -0
  20. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/.github/workflows/release.yml +0 -0
  21. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/.gitignore +0 -0
  22. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/CLAUDE.md +0 -0
  23. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/LICENSE +0 -0
  24. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/README.md +0 -0
  25. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/docs/codegen-coverage-gaps.md +0 -0
  26. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/__init__.py +0 -0
  27. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/__init__.py +0 -0
  28. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/call_handlers.py +0 -0
  29. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/contracts.py +0 -0
  30. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/diagnostics.py +0 -0
  31. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/analyzer/tables.py +0 -0
  32. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/ast_nodes.py +0 -0
  33. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/__init__.py +0 -0
  34. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/base.py +0 -0
  35. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/emit_top.py +0 -0
  36. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/helpers.py +0 -0
  37. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/input.py +0 -0
  38. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/security.py +0 -0
  39. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/codegen/ta.py +0 -0
  40. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/errors.py +0 -0
  41. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/lexer.py +0 -0
  42. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/parser.py +0 -0
  43. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/pragmas.py +0 -0
  44. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/signatures.py +0 -0
  45. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/symbols.py +0 -0
  46. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/tokens.py +0 -0
  47. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pineforge_codegen/tv_input_choices.py +0 -0
  48. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/pyproject.toml +0 -0
  49. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/__init__.py +0 -0
  50. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/_compile.py +0 -0
  51. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_analyzer.py +0 -0
  52. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_analyzer_matrix_inference.py +0 -0
  53. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_analyzer_ta_return_types.py +0 -0
  54. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_codegen_fallthrough_guards.py +0 -0
  55. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_codegen_golden.py +0 -0
  56. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_codegen_input_getters.py +0 -0
  57. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_codegen_matrix_typed.py +0 -0
  58. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_codegen_new.py +0 -0
  59. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_compile_corpus.py +0 -0
  60. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_compile_smoke.py +0 -0
  61. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_errors.py +0 -0
  62. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_input_time_int64.py +0 -0
  63. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_int64_time_storage.py +0 -0
  64. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_lexer.py +0 -0
  65. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_official_surface.py +0 -0
  66. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_parser.py +0 -0
  67. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_security_tf_literal.py +0 -0
  68. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker.py +0 -0
  69. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_chart_visible_bar_time.py +0 -0
  70. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_color_cast.py +0 -0
  71. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_dividends_earnings.py +0 -0
  72. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_footprint.py +0 -0
  73. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_input_color.py +0 -0
  74. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_input_source.py +0 -0
  75. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_matrix.py +0 -0
  76. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_security_adjustment.py +0 -0
  77. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_timeframe_from_seconds.py +0 -0
  78. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_varip.py +0 -0
  79. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_support_checker_volume_row.py +0 -0
  80. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_symbols.py +0 -0
  81. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_ta_official_surface.py +0 -0
  82. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_transpile_division.py +0 -0
  83. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_transpile_enum_order.py +0 -0
  84. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_transpile_pf_trace.py +0 -0
  85. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_transpile_tr_handle_na.py +0 -0
  86. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_transpiler_matrix_kwargs.py +0 -0
  87. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_typespec_matrix.py +0 -0
  88. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_udt_drawing_field_cleanup.py +0 -0
  89. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_unsupported_reporting.py +0 -0
  90. {pineforge_codegen-0.6.5 → pineforge_codegen-0.7.0}/tests/test_vwap_tuple_unpack.py +0 -0
@@ -0,0 +1,32 @@
1
+ # Legal information
2
+
3
+ Summary of licensing, third-party components, and trademarks for `pineforge-codegen`. **Not** legal advice; consult counsel for your use case.
4
+
5
+ ## License
6
+
7
+ `pineforge-codegen` is **source-available**, **not** OSI "open source." It is distributed under the **PolyForm Noncommercial License 1.0.0** with two supplemental terms — see [LICENSE](LICENSE), which is the controlling text:
8
+
9
+ - **Personal Trading exception** — free to research, backtest, and trade your **own** capital, for an individual acting on their own behalf.
10
+ - **Commercial use** — companies, funds, managing third-party capital, embedding in a product, or operating a hosted/public-facing service requires a **commercial license** (email **luis@4pass.com.tw**).
11
+
12
+ Describe this project as **"source-available"** rather than "open source." The runtime it targets, [`pineforge-engine`](https://github.com/pineforge-4pass/pineforge-engine), is separate and **Apache-2.0**.
13
+
14
+ ## What this repository is
15
+
16
+ A pure-Python PineScript v6 → C++ transpiler that emits source against the public PineForge engine C-ABI. It implements PineScript v6 from **TradingView's publicly published language documentation**; it does **not** incorporate TradingView proprietary source. Names like "PineScript v6" are used **nominatively** to describe the input language.
17
+
18
+ ## Third-party components
19
+
20
+ The transpiler has **no runtime dependencies** (one function, `transpile()`). Development/test extras declared in `pyproject.toml` (`pytest`, etc.) are under their own upstream licenses. The opt-in compile checks invoke a C++ compiler against the separately-licensed Apache-2.0 engine headers; they skip cleanly without an engine checkout.
21
+
22
+ ## Trademarks and affiliation
23
+
24
+ **TradingView** and **PineScript** are trademarks of their respective owners. `pineforge-codegen` is **not** affiliated with, endorsed by, or certified by TradingView. References to "PineScript v6" and any compatibility/parity statements are **nominative** and factual — compatibility and technical testing only, not a partnership or certification.
25
+
26
+ ## Contributions
27
+
28
+ Contributions are accepted under a Developer Certificate of Origin (`Signed-off-by`). Because this project is **dual-licensed** (source-available + a sold commercial license), material contributions require a Contributor License Agreement granting PineForge the right to include the contribution in the commercial license; otherwise it cannot be accepted. See `CONTRIBUTING` (or contact luis@4pass.com.tw) before opening a material PR.
29
+
30
+ ## No warranty
31
+
32
+ Provided **"AS IS"**, without warranty of any kind, per the LICENSE's no-warranty / no-liability terms. Transpiler output and any downstream backtest results are **not** investment advice and carry no warranty of trading outcomes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pineforge-codegen
3
- Version: 0.6.5
3
+ Version: 0.7.0
4
4
  Summary: PineScript v6 to C++ transpiler that targets the pineforge-engine runtime.
5
5
  Project-URL: Homepage, https://github.com/pineforge-4pass/pineforge-codegen-oss
6
6
  Project-URL: Issues, https://github.com/pineforge-4pass/pineforge-codegen-oss/issues
@@ -0,0 +1 @@
1
+ 0.7.0
@@ -1426,9 +1426,12 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1426
1426
  if ns == "order":
1427
1427
  return PineType.INT
1428
1428
 
1429
- # format.* constants
1429
+ # format.* constants — Pine ``const string`` ("mintick",
1430
+ # "percent", ...). Codegen emits them as std::string literals
1431
+ # (consumed by pine_str_tostring), so bare reads must type STRING
1432
+ # for the declared C++ variable to match.
1430
1433
  if ns == "format":
1431
- return PineType.INT
1434
+ return PineType.STRING
1432
1435
 
1433
1436
  # text.* constants (align_left, align_right, etc.)
1434
1437
  if ns == "text":
@@ -132,6 +132,17 @@ class TypeHelper:
132
132
  first = self._visit(value.args[0])
133
133
  return TypeSpec.array(self._pine_type_to_spec(first))
134
134
  return TypeSpec.array(TypeSpec.primitive("float"))
135
+ # Functional-form array element/copy accessors: the receiver is
136
+ # the first argument (``array.copy(arr)``), mirroring the
137
+ # method-form handling below (``arr.copy()``).
138
+ if (ns == "array" and value.args
139
+ and func in ("copy", "slice", "get", "first", "last",
140
+ "pop", "shift", "remove")):
141
+ arg_spec = self._type_spec_from_expr(value.args[0])
142
+ if arg_spec is not None and arg_spec.kind == "array":
143
+ if func in ("copy", "slice"):
144
+ return arg_spec
145
+ return arg_spec.element
135
146
  if ns == "matrix" and func == "new":
136
147
  if targs:
137
148
  elem = self._type_spec_from_hint(targs[0]) or TypeSpec.udt(targs[0])
@@ -7,7 +7,7 @@ engine changes:
7
7
  - ``_pf_derive_main_tickerid(tickerid)`` — strip futures suffix from tickerid
8
8
  e.g., ``"CME_MINI:ES1!"`` → ``"CME_MINI:ES"``, ``"NASDAQ:AAPL"`` → ``"NASDAQ:AAPL"``
9
9
  - ``_pf_derive_country(tickerid)`` — lookup country by exchange prefix
10
- e.g., ``"NASDAQ:AAPL"`` → ``"US"``, ``"LSE:BP"`` → ``"UK"``
10
+ e.g., ``"NASDAQ:AAPL"`` → ``"US"``, ``"LSE:BP"`` → ``"GB"`` (ISO 3166-1)
11
11
 
12
12
  These are emitted as ``static inline`` free functions before the
13
13
  ``GeneratedStrategy`` class definition. They depend only on ``<string>``
@@ -15,7 +15,12 @@ and ``<regex>`` (both already pulled in by the standard includes block).
15
15
  """
16
16
 
17
17
  # Prefix → country lookup table used for ``syminfo.country`` derivation.
18
- # Mirrors Pine v6 semantics best-effort; not an exhaustive list.
18
+ # Mirrors Pine v6 semantics best-effort; not an exhaustive list. All values
19
+ # MUST be ISO 3166-1 alpha-2 codes (Pine returns ISO codes: LSE → "GB", not
20
+ # "UK"). Prefixes with no single ISO country (pan-European EURONEXT, global
21
+ # crypto venues BINANCE/KRAKEN/BYBIT/OKX/BITMEX/DERIBIT) are intentionally
22
+ # absent — the helper returns na<std::string>() for them, matching TV's na
23
+ # for symbols without a listing country.
19
24
  PREFIX_TO_COUNTRY: dict[str, str] = {
20
25
  "NASDAQ": "US",
21
26
  "NYSE": "US",
@@ -28,24 +33,17 @@ PREFIX_TO_COUNTRY: dict[str, str] = {
28
33
  "CBOT": "US",
29
34
  "COMEX": "US",
30
35
  "OTC": "US",
31
- "LSE": "UK",
32
- "AQUIS": "UK",
36
+ "LSE": "GB",
37
+ "AQUIS": "GB",
33
38
  "TSE": "JP",
34
39
  "OSE": "JP",
35
40
  "HKEX": "HK",
36
41
  "SGX": "SG",
37
42
  "ASX": "AU",
38
- "EURONEXT": "EU",
39
43
  "XETRA": "DE",
40
44
  "BSE": "IN",
41
45
  "NSE": "IN",
42
- "BINANCE": "GLOBAL",
43
46
  "COINBASE": "US",
44
- "KRAKEN": "GLOBAL",
45
- "BYBIT": "GLOBAL",
46
- "OKX": "GLOBAL",
47
- "BITMEX": "GLOBAL",
48
- "DERIBIT": "GLOBAL",
49
47
  "UPBIT": "KR",
50
48
  "KRX": "KR",
51
49
  "KOSPI": "KR",
@@ -28,6 +28,53 @@ BAR_FIELDS = {
28
28
  "low": "current_bar_.low", "volume": "current_bar_.volume",
29
29
  }
30
30
 
31
+ # struct-tm field extraction expressions for the time/date builtins. Shared
32
+ # between the bare variable forms (``hour`` -> BAR_BUILTINS below) and the
33
+ # function forms (``hour(time[, tz])`` in visit_call.py) so the two cannot
34
+ # drift apart numerically.
35
+ TIME_FIELD_EXPRS = {
36
+ "year": "tm_buf.tm_year + 1900",
37
+ "month": "tm_buf.tm_mon + 1",
38
+ "dayofmonth": "tm_buf.tm_mday",
39
+ "dayofweek": "tm_buf.tm_wday + 1",
40
+ "hour": "tm_buf.tm_hour",
41
+ "minute": "tm_buf.tm_min",
42
+ "second": "tm_buf.tm_sec",
43
+ "weekofyear": "(tm_buf.tm_yday + 7 - ((tm_buf.tm_wday + 6) % 7)) / 7",
44
+ }
45
+
46
+
47
+ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
48
+ """Inline C++ lambda decomposing a Unix-ms timestamp in a timezone.
49
+
50
+ UTC / "" / "Etc/UTC" stays on the cheap ``gmtime_r`` fast path (matching
51
+ the engine's ``_decompose_bar_time``); anything else takes a
52
+ mutex-guarded setenv+``localtime_r`` block, mirroring
53
+ ``pine_tz::ScopedTimezone`` (src/timezone.cpp) which is not exposed via
54
+ any public ``<pineforge/...>`` header today.
55
+ """
56
+ return (
57
+ "[&]() -> int { "
58
+ f"std::string _tz = ({tz_arg}); "
59
+ f"time_t _secs = (time_t)(({ts_arg}) / 1000); "
60
+ "struct tm tm_buf; "
61
+ "if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") { "
62
+ "gmtime_r(&_secs, &tm_buf); "
63
+ "} else { "
64
+ "static std::mutex _pf_tz_mu; "
65
+ "std::lock_guard<std::mutex> _pf_tz_lock(_pf_tz_mu); "
66
+ "const char* _old = std::getenv(\"TZ\"); "
67
+ "std::string _old_tz = _old ? _old : \"\"; bool _had_old = (_old != nullptr); "
68
+ "::setenv(\"TZ\", _tz.c_str(), 1); ::tzset(); "
69
+ "localtime_r(&_secs, &tm_buf); "
70
+ "if (_had_old) { ::setenv(\"TZ\", _old_tz.c_str(), 1); } "
71
+ "else { ::unsetenv(\"TZ\"); } ::tzset(); "
72
+ "} "
73
+ f"return {field_expr}; "
74
+ "}()"
75
+ )
76
+
77
+
31
78
  BAR_BUILTINS = {
32
79
  "bar_index": "bar_index_",
33
80
  "time": "current_bar_.timestamp",
@@ -42,15 +89,20 @@ BAR_BUILTINS = {
42
89
  "hlc3": "((current_bar_.high + current_bar_.low + current_bar_.close) / 3.0)",
43
90
  "hlcc4": "((current_bar_.high + current_bar_.low + current_bar_.close + current_bar_.close) / 4.0)",
44
91
  "ohlc4": "((current_bar_.open + current_bar_.high + current_bar_.low + current_bar_.close) / 4.0)",
45
- # Time/date extraction from bar timestamp (UTC).
46
- "hour": "_bar_hour()",
47
- "minute": "_bar_minute()",
48
- "second": "_bar_second()",
49
- "dayofmonth": "_bar_dayofmonth()",
50
- "dayofweek": "_bar_dayofweek()",
51
- "month": "_bar_month()",
52
- "year": "_bar_year()",
53
- "weekofyear": "_bar_weekofyear()",
92
+ # Time/date extraction from the bar timestamp. Pine v6 specifies the
93
+ # EXCHANGE timezone (syminfo.timezone) for the bare variable forms, so
94
+ # they route through the same timezone-aware lambda as the function
95
+ # forms ``hour(time[, tz])`` instead of the engine's UTC-only
96
+ # ``_bar_hour()`` helpers. For UTC-exchange data (the crypto corpus,
97
+ # SymInfo's constructor default) the lambda takes the gmtime_r fast
98
+ # path and is value-identical to the old emission.
99
+ **{
100
+ name: tz_time_field_lambda(
101
+ TIME_FIELD_EXPRS[name], "current_bar_.timestamp", "syminfo_.timezone"
102
+ )
103
+ for name in ("hour", "minute", "second", "dayofmonth", "dayofweek",
104
+ "month", "year", "weekofyear")
105
+ },
54
106
  }
55
107
 
56
108
  BAR_SERIES_PUSH = {
@@ -308,9 +360,12 @@ ADJUSTMENT_MAP = {"none": "0", "dividends": "1", "splits": "2"}
308
360
 
309
361
  # display.* (plot_display) constants — ints for C++. TV uses these in chart
310
362
  # settings; the backtest ignores them. Unknown member falls back to "all" (0).
363
+ # The integer codes are inert downstream (no engine consumer); they only need
364
+ # to be distinct so constant-equality comparisons behave.
311
365
  DISPLAY_MAP = {
312
366
  "all": "0", "none": "1", "pane": "2",
313
367
  "data_window": "3", "status_line": "4", "price_scale": "5",
368
+ "pine_screener": "6",
314
369
  }
315
370
 
316
371
  # order.* sort-direction constants — emitted as std::string literals. Unknown
@@ -359,8 +414,23 @@ ARRAY_METHODS = {
359
414
  "range": lambda a, args: f"(*std::max_element({a}.begin(),{a}.end())-*std::min_element({a}.begin(),{a}.end()))",
360
415
  "every": lambda a, args: f"std::all_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
361
416
  "some": lambda a, args: f"std::any_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
362
- "stdev": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return std::sqrt(s/{a}.size()); }}()",
363
- "variance": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return s/{a}.size(); }}()",
417
+ # stdev/variance honor the optional 2nd ``biased`` arg (Pine v6:
418
+ # biased=true population (default), false sample / n-1). The no-arg
419
+ # form keeps the original population emission byte-identical.
420
+ "stdev": lambda a, args: (
421
+ f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
422
+ f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
423
+ f"return _d>0?std::sqrt(s/_d):na<double>(); }}()"
424
+ if args else
425
+ f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return std::sqrt(s/{a}.size()); }}()"
426
+ ),
427
+ "variance": lambda a, args: (
428
+ f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
429
+ f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
430
+ f"return _d>0?s/_d:na<double>(); }}()"
431
+ if args else
432
+ f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return s/{a}.size(); }}()"
433
+ ),
364
434
  "median": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); int n=c.size(); return n%2?c[n/2]:(c[n/2-1]+c[n/2])/2.0; }}()",
365
435
  "mode": lambda a, args: f"[&](){{ std::unordered_map<double,int> m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v<best)){{bc=c;best=v;}} return best; }}()",
366
436
  "percentile_linear_interpolation": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); double k=({args[0]}/100.0)*c.size()-0.5; int i=std::max(0,(int)k); double f=k-i; if(i+1>=(int)c.size()) return c.back(); return c[i]*(1-f)+c[i+1]*f; }}()",
@@ -209,6 +209,17 @@ class TypeInferer:
209
209
  if func_name == "from" and node.args:
210
210
  return TypeSpec.array(self._type_spec_from_expr(node.args[0]) or TypeSpec.primitive("float"))
211
211
  return TypeSpec.array(TypeSpec.primitive("float"))
212
+ # Functional-form array element/copy accessors: the receiver is
213
+ # the first argument (``array.copy(arr)``), mirroring the
214
+ # method-form handling below (``arr.copy()``).
215
+ if (namespace == "array" and node.args
216
+ and func_name in ("copy", "slice", "get", "first", "last",
217
+ "pop", "shift", "remove")):
218
+ arg_spec = self._type_spec_from_expr(node.args[0])
219
+ if arg_spec is not None and arg_spec.kind == "array":
220
+ if func_name in ("copy", "slice"):
221
+ return arg_spec
222
+ return arg_spec.element
212
223
  if namespace == "map" and func_name == "new":
213
224
  key = self._type_spec_from_hint_name(targs[0]) if len(targs) > 0 else TypeSpec.primitive("string")
214
225
  val = self._type_spec_from_hint_name(targs[1]) if len(targs) > 1 else TypeSpec.primitive("float")
@@ -471,6 +482,10 @@ class TypeInferer:
471
482
  return "std::string"
472
483
  if namespace == "ta" and func_name == "pivot_point_levels":
473
484
  return "std::vector<double>"
485
+ # array.join returns string in both the functional and the
486
+ # method-call forms.
487
+ if namespace == "array" and func_name == "join":
488
+ return "std::string"
474
489
  if isinstance(node.callee, MemberAccess):
475
490
  member_name = func_name or node.callee.member
476
491
  recv_spec = self._type_spec_from_expr(node.callee.object)
@@ -508,6 +523,10 @@ class TypeInferer:
508
523
  ename = node.object.name
509
524
  if ename in self._enum_defs and node.member in self._enum_defs[ename]:
510
525
  return "int"
526
+ # format.* constants emit std::string literals (consumed by
527
+ # pine_str_tostring); bare reads must declare std::string.
528
+ if ename == "format":
529
+ return "std::string"
511
530
  # syminfo.* type inference: look up in SYMINFO_MEMBER_MAP
512
531
  # and derive C++ type from the expression (na<T>() or function call).
513
532
  if ename == "syminfo":
@@ -155,11 +155,46 @@ from .tables import (
155
155
  SKIP_NAMESPACES,
156
156
  SKIP_VAR_TYPES,
157
157
  STR_FUNC_MAP,
158
+ TIME_FIELD_EXPRS,
158
159
  _merge_kwargs,
159
160
  _merge_kwargs_with_defaults,
161
+ tz_time_field_lambda,
160
162
  )
161
163
 
162
164
 
165
+ def _parse_pine_datestring_ms(text: str) -> int | None:
166
+ """Parse a Pine ``timestamp(dateString)`` literal to Unix milliseconds.
167
+
168
+ Pine v6 accepts ISO-8601 strings ("2025-01-01", "2011-10-10T14:48:00",
169
+ with optional offset) and the "DD MMM YYYY hh:mm:ss ±HHMM" /
170
+ "MMM DD YYYY ..." forms. A dateString without a time zone is GMT+0 per
171
+ the Pine reference. Returns None when the string cannot be parsed.
172
+ """
173
+ from datetime import datetime, timezone
174
+
175
+ txt = text.strip()
176
+ dt = None
177
+ try:
178
+ dt = datetime.fromisoformat(txt)
179
+ except ValueError:
180
+ for fmt in (
181
+ "%d %b %Y %H:%M:%S %z", "%d %b %Y %H:%M %z",
182
+ "%d %b %Y %H:%M:%S", "%d %b %Y %H:%M", "%d %b %Y",
183
+ "%b %d %Y %H:%M:%S %z", "%b %d %Y %H:%M %z",
184
+ "%b %d %Y %H:%M:%S", "%b %d %Y %H:%M", "%b %d %Y",
185
+ ):
186
+ try:
187
+ dt = datetime.strptime(txt, fmt)
188
+ break
189
+ except ValueError:
190
+ continue
191
+ if dt is None:
192
+ return None
193
+ if dt.tzinfo is None:
194
+ dt = dt.replace(tzinfo=timezone.utc)
195
+ return int(dt.timestamp() * 1000)
196
+
197
+
163
198
  class CallVisitor:
164
199
  """Function-call dispatch visitor methods shared across the codegen.
165
200
 
@@ -499,23 +534,22 @@ class CallVisitor:
499
534
  # weekofyear(time[, tz]).
500
535
  #
501
536
  # Pine v6 exposes these names as BOTH variables (current bar) AND
502
- # functions (arbitrary timestamp). The variable form is wired by
503
- # ``BAR_BUILTINS`` in codegen/tables.py to ``_bar_year()`` etc. The
504
- # function form has no public runtime helper, so we inline the
505
- # gmtime_r-based calculation that mirrors ``BacktestEngine::
506
- # _decompose_bar_time()`` (see include/pineforge/engine.hpp) so the
507
- # numbers agree across both forms.
537
+ # functions (arbitrary timestamp). Both forms now share the same
538
+ # timezone-aware emission: the variable form is wired by
539
+ # ``BAR_BUILTINS`` in codegen/tables.py to
540
+ # ``tz_time_field_lambda(..., current_bar_.timestamp,
541
+ # syminfo_.timezone)`` and the function form below uses the same
542
+ # builder, so the numbers agree across both forms.
508
543
  #
509
544
  # Timezone handling (per Pine v6 reference docs):
510
545
  # - Bare form ``hour(time)`` defaults its tz argument to
511
546
  # ``syminfo.timezone`` — the SYMBOL/EXCHANGE timezone, NOT the
512
547
  # chart's display timezone. For the corpus' ETH-USDT crypto data
513
548
  # this is ``"UTC"`` (the ``SymInfo`` constructor default), which
514
- # keeps the lambda on the cheap ``gmtime_r`` fast path. The
515
- # variable form ``hour`` is wired directly to ``_bar_hour()`` /
516
- # ``_decompose_bar_time()`` (engine.hpp), which is hardcoded to
517
- # ``gmtime_r`` and therefore matches the function-form default
518
- # for the same exchange TZ.
549
+ # keeps the lambda on the cheap ``gmtime_r`` fast path
550
+ # value-identical to the engine's ``_bar_hour()`` /
551
+ # ``_decompose_bar_time()`` (engine.hpp) UTC helpers the variable
552
+ # form used to bind to.
519
553
  #
520
554
  # Pre-fix the harness's ``strategy_set_chart_timezone`` clobbered
521
555
  # ``syminfo_.timezone`` with the chart display TZ, which silently
@@ -529,26 +563,16 @@ class CallVisitor:
529
563
  # - Two-arg form ``hour(time, tz)`` always overrides syminfo with
530
564
  # the explicit tz argument. Same setenv+localtime_r block as the
531
565
  # 1-arg fallback.
532
- _BAR_TIME_FUNC_EXPR = {
533
- "year": "tm_buf.tm_year + 1900",
534
- "month": "tm_buf.tm_mon + 1",
535
- "dayofmonth": "tm_buf.tm_mday",
536
- "dayofweek": "tm_buf.tm_wday + 1",
537
- "hour": "tm_buf.tm_hour",
538
- "minute": "tm_buf.tm_min",
539
- "second": "tm_buf.tm_sec",
540
- "weekofyear": "(tm_buf.tm_yday + 7 - ((tm_buf.tm_wday + 6) % 7)) / 7",
541
- }
542
566
  if (
543
567
  namespace is None
544
- and func_name in _BAR_TIME_FUNC_EXPR
568
+ and func_name in TIME_FIELD_EXPRS
545
569
  and (node.args or node.kwargs)
546
570
  ):
547
571
  params = sigs.get_param_names(None, func_name)
548
572
  args = _merge_kwargs(node.args, node.kwargs, params, self._visit_expr)
549
573
  ts_arg = args[0] if args else "current_bar_.timestamp"
550
574
  tz_arg = args[1] if len(args) > 1 else None
551
- field_expr = _BAR_TIME_FUNC_EXPR[func_name]
575
+ field_expr = TIME_FIELD_EXPRS[func_name]
552
576
  if tz_arg is None:
553
577
  # 1-arg form — fall back to ``syminfo.timezone`` per TV
554
578
  # docs (the EXCHANGE TZ, default "UTC" for the corpus'
@@ -559,30 +583,11 @@ class CallVisitor:
559
583
  # mutex-guarded setenv+localtime_r block as the 2-arg
560
584
  # form.
561
585
  tz_arg = "syminfo_.timezone"
562
- # 2-arg form — honor the tz argument. Uses an inline setenv+
563
- # localtime_r guarded by a function-local static mutex, mirroring
564
- # ``pine_tz::ScopedTimezone`` (src/timezone.cpp) which is not
565
- # exposed via any public ``<pineforge/...>`` header today.
566
- return (
567
- "[&]() -> int { "
568
- f"std::string _tz = ({tz_arg}); "
569
- f"time_t _secs = (time_t)(({ts_arg}) / 1000); "
570
- "struct tm tm_buf; "
571
- "if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") { "
572
- "gmtime_r(&_secs, &tm_buf); "
573
- "} else { "
574
- "static std::mutex _pf_tz_mu; "
575
- "std::lock_guard<std::mutex> _pf_tz_lock(_pf_tz_mu); "
576
- "const char* _old = std::getenv(\"TZ\"); "
577
- "std::string _old_tz = _old ? _old : \"\"; bool _had_old = (_old != nullptr); "
578
- "::setenv(\"TZ\", _tz.c_str(), 1); ::tzset(); "
579
- "localtime_r(&_secs, &tm_buf); "
580
- "if (_had_old) { ::setenv(\"TZ\", _old_tz.c_str(), 1); } "
581
- "else { ::unsetenv(\"TZ\"); } ::tzset(); "
582
- "} "
583
- f"return {field_expr}; "
584
- "}()"
585
- )
586
+ # 2-arg form — honor the tz argument. The shared
587
+ # ``tz_time_field_lambda`` (codegen/tables.py) also backs the
588
+ # bare variable forms (``hour`` etc. via BAR_BUILTINS), so the
589
+ # numbers agree across both forms.
590
+ return tz_time_field_lambda(field_expr, ts_arg, tz_arg)
586
591
 
587
592
  # time(timeframe) or time(timeframe, session[, tz])
588
593
  if func_name == "time" and namespace is None and (node.args or node.kwargs):
@@ -614,6 +619,43 @@ class CallVisitor:
614
619
  is_tz_first = True
615
620
 
616
621
  if is_tz_first:
622
+ # A single string argument is the timestamp(dateString)
623
+ # overload, NOT the timezone-first form. It used to fall
624
+ # through with year=1970 defaults — silently wrong. Pine
625
+ # dateString is a const string, so parse it at transpile
626
+ # time (common as the input.time defval); reject loudly when
627
+ # it is not a literal or does not parse.
628
+ if len(node.args) == 1:
629
+ if isinstance(node.args[0], StringLiteral):
630
+ ms = _parse_pine_datestring_ms(node.args[0].value)
631
+ if ms is None:
632
+ self._codegen_error(
633
+ node,
634
+ f"timestamp(dateString): could not parse "
635
+ f"'{node.args[0].value}'.",
636
+ hint="Supported forms: ISO-8601 "
637
+ "(\"2025-01-01[THH:MM:SS][±HH:MM]\") and "
638
+ "\"DD MMM YYYY [hh:mm[:ss]] [±HHMM]\" / "
639
+ "\"MMM DD YYYY ...\"; no time zone = "
640
+ "GMT+0.",
641
+ )
642
+ return f"{ms}LL"
643
+ self._codegen_error(
644
+ node,
645
+ "timestamp(dateString) requires a literal string in "
646
+ "PineForge (Pine v6 dateString is a const string).",
647
+ hint="Use a string literal, or timestamp(year, month, "
648
+ "day[, hour, minute, second]).",
649
+ )
650
+ # timezone-first form requires year, month, and day.
651
+ if len(node.args) < 4:
652
+ self._codegen_error(
653
+ node,
654
+ "timestamp(timezone, ...) requires year, month, and "
655
+ "day arguments.",
656
+ hint="Pine v6 signature: timestamp(timezone, year, "
657
+ "month, day[, hour, minute, second]).",
658
+ )
617
659
  args = [self._visit_expr(a) for a in node.args]
618
660
  tz = args[0]
619
661
  yr = args[1] if len(args) > 1 else "1970"
@@ -655,35 +697,47 @@ class CallVisitor:
655
697
  f"}}()"
656
698
  )
657
699
  else:
658
- args = [self._visit_expr(a) for a in node.args]
659
- if len(args) >= 1:
660
- if len(args) == 1:
661
- return "0"
662
- yr = args[0]
663
- mo = args[1] if len(args) > 1 else "1"
664
- dy = args[2] if len(args) > 2 else "1"
665
- hr = args[3] if len(args) > 3 else "0"
666
- mn = args[4] if len(args) > 4 else "0"
667
- sc = args[5] if len(args) > 5 else "0"
668
- return (
669
- f"[&]() -> int64_t {{ "
670
- f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
671
- f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
672
- f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
673
- f"static thread_local int64_t _last_res = -1; "
674
- f"if (_last_res != -1 && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
675
- f"return _last_res; "
676
- f"}} "
677
- f"struct tm t = {{}}; "
678
- f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
679
- f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
680
- f"int64_t _res = (int64_t)timegm(&t) * 1000; "
681
- f"_last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
682
- f"_last_res = _res; "
683
- f"return _res; "
684
- f"}}()"
700
+ # Numeric form requires year, month, and day (hour/minute/
701
+ # second default to 0). Anything shorter used to emit "0".
702
+ merged = _merge_kwargs(
703
+ node.args, node.kwargs,
704
+ sigs.get_param_names(None, "timestamp"),
705
+ lambda a: a,
706
+ )
707
+ if len(merged) < 3 or any(a is None for a in merged[:3]):
708
+ self._codegen_error(
709
+ node,
710
+ f"timestamp(...) with {len(merged)} argument(s) is "
711
+ f"not supported year, month, and day are required.",
712
+ hint="Pine v6 signature: timestamp(year, month, day"
713
+ "[, hour, minute, second]); the dateString "
714
+ "overload is not supported in PineForge.",
685
715
  )
686
- return "0"
716
+ args = [self._visit_expr(a) for a in merged]
717
+ yr = args[0]
718
+ mo = args[1] if len(args) > 1 else "1"
719
+ dy = args[2] if len(args) > 2 else "1"
720
+ hr = args[3] if len(args) > 3 else "0"
721
+ mn = args[4] if len(args) > 4 else "0"
722
+ sc = args[5] if len(args) > 5 else "0"
723
+ return (
724
+ f"[&]() -> int64_t {{ "
725
+ f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
726
+ f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
727
+ f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
728
+ f"static thread_local int64_t _last_res = -1; "
729
+ f"if (_last_res != -1 && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
730
+ f"return _last_res; "
731
+ f"}} "
732
+ f"struct tm t = {{}}; "
733
+ f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
734
+ f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
735
+ f"int64_t _res = (int64_t)timegm(&t) * 1000; "
736
+ f"_last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
737
+ f"_last_res = _res; "
738
+ f"return _res; "
739
+ f"}}()"
740
+ )
687
741
 
688
742
  # barssince() — unsupported. Defensive: support_checker rejects bare
689
743
  # barssince(...) with a hint to use ta.barssince(...). Reaching here
@@ -696,13 +750,27 @@ class CallVisitor:
696
750
 
697
751
  # Type cast functions: int(x), float(x), bool(x), string(x)
698
752
  if func_name == "int" and namespace is None and node.args:
699
- return f"(int)({self._visit_expr(node.args[0])})"
753
+ # Pine int(na) → na (int form). Evaluate once, propagate na via
754
+ # the engine's int sentinel instead of collapsing NaN to 0.
755
+ x = self._visit_expr(node.args[0])
756
+ return (f"[&](){{ double _pf_v = (double)({x}); "
757
+ f"return is_na(_pf_v) ? na<int>() : (int)_pf_v; }}()")
700
758
  if func_name == "float" and namespace is None and node.args:
701
759
  return f"(double)({self._visit_expr(node.args[0])})"
702
760
  if func_name == "bool" and namespace is None and node.args:
703
761
  return f"(bool)({self._visit_expr(node.args[0])})"
704
762
  if func_name == "string" and namespace is None and node.args:
705
- return f"std::to_string({self._visit_expr(node.args[0])})"
763
+ # Pine string(x) cast — same emission as str.tostring(x), with
764
+ # string passthrough and TV-style "true"/"false" for bools
765
+ # (std::to_string would reject strings / render bools as 0/1).
766
+ arg = node.args[0]
767
+ inferred = self._infer_type(arg)
768
+ if inferred == "std::string":
769
+ return self._visit_expr(arg)
770
+ if inferred == "bool":
771
+ visited = self._visit_expr(arg)
772
+ return f'(({visited}) ? std::string("true") : std::string("false"))'
773
+ return self._visit_str_call("tostring", node)
706
774
 
707
775
  # ta.pivot_point_levels — free function, not a stateful indicator
708
776
  if namespace == "ta" and func_name == "pivot_point_levels":
@@ -1226,6 +1294,18 @@ class CallVisitor:
1226
1294
  return f'pine_str_format_time({ts}, {fmt}, {tz})'
1227
1295
 
1228
1296
  if func_name == "replace":
1297
+ if len(args) >= 4:
1298
+ # 4-arg form: replace the Nth occurrence (0-based, per Pine
1299
+ # spec). Out-of-range / negative occurrence → original string.
1300
+ return (
1301
+ f'[&](){{ std::string s={args[0]}; std::string t={args[1]}; '
1302
+ f'std::string r={args[2]}; int _occ=(int)({args[3]}); '
1303
+ f'if(t.empty()||_occ<0) return s; '
1304
+ f'size_t p=0; int _i=0; '
1305
+ f'while((p=s.find(t,p))!=std::string::npos){{ '
1306
+ f'if(_i==_occ){{ s.replace(p,t.length(),r); break; }} '
1307
+ f'p+=t.length(); _i++; }} return s; }}()'
1308
+ )
1229
1309
  if len(args) >= 3:
1230
1310
  return f'[&](){{ std::string s={args[0]}; auto p=s.find({args[1]}); if(p!=std::string::npos) s.replace(p,{args[1]}.length(),{args[2]}); return s; }}()'
1231
1311
  return 'std::string("")'
@@ -1241,8 +1321,10 @@ class CallVisitor:
1241
1321
  if func_name == "round" and len(args) == 2:
1242
1322
  return f"(std::round({args[0]} * std::pow(10.0, {args[1]})) / std::pow(10.0, {args[1]}))"
1243
1323
  if func_name == "round_to_mintick":
1324
+ # Engine method (engine.hpp): NaN- and mintick<=0-guarded,
1325
+ # unlike the previous inlined unguarded std::round.
1244
1326
  x = args[0] if args else "0.0"
1245
- return f"(std::round({x} / syminfo_mintick_) * syminfo_mintick_)"
1327
+ return f"round_to_mintick({x})"
1246
1328
  if func_name == "todegrees":
1247
1329
  x = args[0] if args else "0.0"
1248
1330
  return f"({x} * 180.0 / M_PI)"