Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Python entry points are `pineforge_codegen.transpile()` and
`gate/glue.py` JSON protocol. See `docs/PUBLIC_CONTRACT.md`.

This is the **source-available** half of the PineForge stack (PineForge
Source License 1.0 — see `LICENSE`). The runtime half (`pineforge-engine`,
Source License 1.1 — see `LICENSE`). The runtime half (`pineforge-engine`,
Apache-2.0) lives in a sibling repo and is typically checked out at
`../pineforge-engine`. From 1.0.0 on, a released codegen `X.Y.Z` pairs only
with engine `vX.Y.Z`; prereleases match exactly. Codegen 1.1.0 pairs with
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ An additive change to the public API; it leaves the emitted C++ unchanged.
argument kinds and a one-line explanation; the templates render the
`message` and `hint` byte for byte, which keep their text. Codes are never
reused (`tests/fixtures/diagnostic_codes_pin.json`). See
[Diagnostic codes](docs/PUBLIC_CONTRACT.md#diagnostic-codes).
[Diagnostic codes](docs/PUBLIC_CONTRACT.md#diagnostic-codes). A code is
read off its text in time linear in the text: a crafted script's text cannot
stall the classification, which a backtracking regex let it do.
- **First error in source order.** Since 1.1.0 the settings metadata visited
every input's `defval`, `options`, `minval`, `maxval` and `step` ahead of
the script body, so an error there (an unknown name in `minval`) was raised
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Python entry points are `pineforge_codegen.transpile()` and
`gate/glue.py` JSON protocol. See `docs/PUBLIC_CONTRACT.md`.

This is the **source-available** half of the PineForge stack (PineForge
Source License 1.0 — see `LICENSE`). The runtime half (`pineforge-engine`,
Source License 1.1 — see `LICENSE`). The runtime half (`pineforge-engine`,
Apache-2.0) lives in a sibling repo and is typically checked out at
`../pineforge-engine`. From 1.0.0 on, a released codegen `X.Y.Z` pairs only
with engine `vX.Y.Z`; prereleases match exactly. Codegen 1.1.0 pairs with
Expand Down
4 changes: 3 additions & 1 deletion LEGAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Summary of licensing, third-party components, and trademarks for `pineforge-code

## License

`pineforge-codegen` is **source-available**, **not** OSI "open source." It is distributed under the **PineForge Source License 1.0** — see [LICENSE](LICENSE), which is the controlling text.
`pineforge-codegen` is **source-available**, **not** OSI "open source." It is distributed under the **PineForge Source License 1.1** — see [LICENSE](LICENSE), which is the controlling text.

- **Noncommercial use** — any noncommercial purpose, and use by a charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization or government institution for its teaching, research and other operations, is free.
- **Personal Trading** — free for a natural person to research, develop or backtest strategies and trade their **own** account with their **own** capital. Household, joint and retirement accounts and ordinary margin count as their own; a company's or fund's account does not, even a company they wholly own.
Expand All @@ -15,6 +15,8 @@ Describe this project as **"source-available"** rather than "open source." The r

Releases up to and including 1.1.0 were published under the license text that came with them (the PolyForm Noncommercial License 1.0.0 with a PineForge supplement); copies of those releases keep that license. The PineForge Source License 1.0 is a separate license and is not a PolyForm license.

The PineForge Source License 1.1 replaces 1.0 for the code on `main` and in future releases; it clarifies one point, that distributing the software or its output, changed or not, embedded in or bundled with a product or service made available to others is Commercial Use, not free distribution, unless it is for a permitted purpose.

## Copyright and licensor

The licensor is **pineforge, LLC**, a Delaware limited liability company. It holds the copyright in `pineforge-codegen`; the founder's rights in the software are assigned to it. Commercial licenses are granted by pineforge, LLC.
Expand Down
17 changes: 11 additions & 6 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PineForge Source License 1.0
PineForge Source License 1.1
============================

Copyright 2025-2026 pineforge, LLC
Expand Down Expand Up @@ -31,8 +31,12 @@ License section.
The licensor grants you an additional copyright license to distribute copies
of the software. Your license to distribute covers distributing the software
with changes and new works permitted by the Changes and New Works License
section. Distributing copies under this section is not Commercial Use, and it
gives the people who get them no license beyond these terms.
section. Unless it is a permitted purpose, distributing the software, changed
or not, embedded in or bundled with a product or service made available to
others is Commercial Use under (3) of the Commercial Use section, which your
license to distribute does not cover. Distributing copies under this section
is not Commercial Use, and it gives the people who get them no license beyond
these terms.


## Notices
Expand Down Expand Up @@ -162,9 +166,10 @@ Use:
organization, including use by an individual in the course of work for
such an organization;

(3) embedding the software, or its Output, into any product or service
made available to others, including using the software to generate
Output for such a product or service; and
(3) embedding the software or its Output, changed or not, into any product
or service made available to others, or distributing either bundled
with such a product or service, including using the software to
generate Output for such a product or service; and

(4) operating any hosted, software-as-a-service, or otherwise public-facing
service through which others can run the software or receive its
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ A pure-Python library that turns a PineScript v6 strategy into a complete C++
source file you can compile against the [`pineforge-engine`](https://github.com/pineforge-4pass/pineforge-engine)
runtime.

**Measured <!-- pf:scoreboard.date -->2026-10-04<!-- /pf -->** on main engine <!-- pf:scoreboard.engineCommit|short-code -->`7b596622`<!-- /pf --> with codegen-oss <!-- pf:scoreboard.codegenCommit|short-code -->`c5d97ee5`<!-- /pf --> (baseline <!-- pf:scoreboard.id|code -->`pineforge-parity-baseline-20261004-engine-7b596622`<!-- /pf -->, snapshot <!-- pf:scoreboard.snapshotSha256|short-code -->`af86f13e`<!-- /pf -->): <!-- pf:scoreboard.excellent|int -->7,951<!-- /pf --> of <!-- pf:scoreboard.graded|int -->7,989<!-- /pf --> TradingView probes
graded excellent and <!-- pf:scoreboard.strong|int -->38<!-- /pf --> strong, with <!-- pf:scoreboard.belowStrong|int -->0<!-- /pf --> below strong; <!-- pf:scoreboard.anomaliesExcluded|int -->17<!-- /pf --> more probes are held out as TradingView-side anomalies.
**Measured <!-- pf:scoreboard.date -->2026-10-04<!-- /pf -->** on main engine <!-- pf:scoreboard.engineCommit|short-code -->`6b77f061`<!-- /pf --> with codegen-oss <!-- pf:scoreboard.codegenCommit|short-code -->`285ac035`<!-- /pf --> (baseline <!-- pf:scoreboard.id|code -->`pineforge-parity-baseline-20261004-engine-6b77f061`<!-- /pf -->, snapshot <!-- pf:scoreboard.snapshotSha256|short-code -->`21639bad`<!-- /pf -->): <!-- pf:scoreboard.excellent|int -->7,970<!-- /pf --> of <!-- pf:scoreboard.graded|int -->7,989<!-- /pf --> TradingView probes
graded excellent and <!-- pf:scoreboard.strong|int -->19<!-- /pf --> strong, with <!-- pf:scoreboard.belowStrong|int -->0<!-- /pf --> below strong; <!-- pf:scoreboard.anomaliesExcluded|int -->17<!-- /pf --> more probes are held out as TradingView-side anomalies.
A probe is a strategy exported from TradingView with its trade list and replayed
trade for trade on the same bars.

Expand Down Expand Up @@ -531,7 +531,7 @@ use `python -m pytest --collect-only -q` for the current collection count.

## License

Source-available under the [PineForge Source License 1.0](https://github.com/pineforge-4pass/pineforge-codegen-oss/blob/main/LICENSE);
Source-available under the [PineForge Source License 1.1](https://github.com/pineforge-4pass/pineforge-codegen-oss/blob/main/LICENSE);
the `LICENSE` file is the controlling text. The licensor is pineforge, LLC.

- **Free for noncommercial use:** any noncommercial purpose, and use by a
Expand All @@ -556,10 +556,10 @@ the `LICENSE` file is the controlling text. The licensor is pineforge, LLC.
- **Commercial Use needs a commercial license:** besides investment
management, any other use that is not free, such as use by, for or on
behalf of a company, fund, partnership or other organization (including an
individual's work for one); embedding the software or its output in a
product or service made available to others; or operating a hosted,
software-as-a-service or other public-facing service through which others
run the software or receive its output.
individual's work for one); embedding the software or its output in, or
distributing either bundled with, a product or service made available to
others; or operating a hosted, software-as-a-service or other public-facing
service through which others run the software or receive its output.

This is source-available, not OSI open source.

Expand Down
143 changes: 119 additions & 24 deletions pineforge_codegen/diagnostic_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
never guessed: a text no template renders gets the uncatalogued code of its
severity (``PF-E0000`` / ``PF-W0000``), which the test suite refuses.

The match never backtracks over the text's characters: each literal segment
of a template goes to its leftmost place after the previous one, the last to
the text's end, and every argument is the text between its literals -- the
split a fullmatch of the template with lazy ``(.*?)`` arguments returns. Where
an argument is named twice (``{receiver}`` in the message and the hint) the
leftmost split can name it two values; then the later places of a literal are
tried in order, as the regex's backtracking tries them, within a fixed budget
of steps. The regex took seconds, then minutes, as a crafted message grew,
and a user's script spells argument text.

Rendering (:func:`render`) is the ICU MessageFormat subset the catalog uses:
literal text with ICU apostrophe quoting (``''`` is one apostrophe, ``'{'``
a literal brace) and simple ``{name}`` arguments, a string argument
Expand Down Expand Up @@ -158,47 +168,133 @@ def render_diagnostic(code: str, args: dict) -> tuple[str, str | None]:
# Classification
# ---------------------------------------------------------------------------

# Joins a message and its hint into one subject, so an argument both name is
# one value (a backreference). No template spells it.
_JOIN = "\x00"
_CANONICAL_INT = re.compile(r"-?(?:0|[1-9][0-9]*)")
_CANONICAL_FLOAT = re.compile(r"-?(?:0|[1-9][0-9]*)\.[0-9]+")


def _segments(parts: list) -> tuple[str, tuple[tuple[str, str], ...]]:
"""A parsed template as its leading literal and ``(argument, literal after
it)`` pairs; the literal after an argument may be empty."""
head = ""
pairs: list[list[str]] = []
for part in parts:
if isinstance(part, str):
if pairs:
pairs[-1][1] += part
else:
head += part
else:
pairs.append([part[0], ""])
return head, tuple((name, literal) for name, literal in pairs)


# Literal places a template whose argument is named twice may try, per
# classification: a script's text never needs more than a few, and a crafted
# one gets the uncatalogued code instead of a long search.
_SEARCH_BUDGET = 4096


def _search(segments: list, texts: list[str], repeats: bool) -> dict[str, str] | None:
"""Split ``texts`` (the message, then the hint) by their templates'
``segments``: each argument ends at the leftmost place of the literal after
it, the last argument of a text at its end -- the split a lazy-regex
fullmatch returns. Without an argument named twice (``repeats``) that split
succeeds whenever any does, since a leftmost place leaves the rest the most
room: it is the only one tried, so the work is linear. With one, a split can
give the name two values; then the later places of a literal are tried in
order, as the regex backtracks, within ``_SEARCH_BUDGET`` places."""
starts: list[int] = []
ends: list[int] = []
slots: list[tuple[int, str, str, bool]] = [] # text, argument, literal after it, last
for index, ((head, pairs), text) in enumerate(zip(segments, texts)):
if not text.startswith(head):
return None
if not pairs:
if len(text) != len(head):
return None
starts.append(len(head))
ends.append(len(head))
continue
tail = pairs[-1][1]
end = len(text) - len(tail)
if end < len(head) or not text.endswith(tail):
return None
starts.append(len(head))
ends.append(end)
for at, (name, literal) in enumerate(pairs):
slots.append((index, name, literal, at == len(pairs) - 1))
found: dict[str, str] = {}
budget = [_SEARCH_BUDGET]

def step(slot: int, pos: int) -> bool:
if slot == len(slots):
return True
text_index, name, literal, last = slots[slot]
text, end = texts[text_index], ends[text_index]
following = slot + 1
known = found.get(name)
if last:
value = text[pos:end]
resume = (starts[slots[following][0]] if following < len(slots) else end)
if known is not None:
return known == value and step(following, resume)
found[name] = value
if step(following, resume):
return True
del found[name]
return False
if known is not None:
stop = pos + len(known)
return (text.startswith(known, pos) and stop + len(literal) <= end
and text.startswith(literal, stop) and step(following, stop + len(literal)))
at = text.find(literal, pos, end)
while at >= 0:
budget[0] -= 1
if budget[0] < 0:
return False
found[name] = text[pos:at]
if step(following, at + len(literal)):
return True
del found[name]
if not repeats:
return False
at = text.find(literal, at + 1, end) if at < end else -1
return False

first = starts[slots[0][0]] if slots else 0
return found if step(0, first) else None


class _Matcher:
__slots__ = ("code", "has_hint", "prefix", "suffix", "specificity",
"_parts", "_regex", "_kinds")
"_message", "_hint", "_kinds", "_repeats")

def __init__(self, code: str, entry: dict):
self.code = code
message = parse_template(entry["message"])
hint = entry.get("hint")
hint_parts = parse_template(hint) if hint is not None else []
self.has_hint = hint is not None
self._parts = message + ([_JOIN] + parse_template(hint) if hint is not None else [])
self._message = _segments(message)
self._hint = _segments(hint_parts) if hint is not None else None
self.prefix = message[0] if message and isinstance(message[0], str) else ""
self.suffix = message[-1] if message and isinstance(message[-1], str) else ""
self.specificity = sum(len(p) for p in self._parts if isinstance(p, str))
self._regex = None
# The text a template spells itself; the hint's separator counted as one
# character, as the order of the catalog's codes has always assumed.
self.specificity = (sum(len(p) for p in message + hint_parts if isinstance(p, str))
+ (1 if hint is not None else 0))
self._kinds = {name: spec.get("kind") for name, spec in entry.get("args", {}).items()}
names = [part[0] for part in message + hint_parts if isinstance(part, tuple)]
self._repeats = len(names) != len(set(names))

def match(self, subject: str) -> dict | None:
if self._regex is None:
pieces: list[str] = []
seen: set[str] = set()
for part in self._parts:
if isinstance(part, str):
pieces.append(re.escape(part))
elif part[0] in seen:
pieces.append(f"(?P={part[0]})")
else:
seen.add(part[0])
pieces.append(f"(?P<{part[0]}>.*?)")
self._regex = re.compile("".join(pieces), re.DOTALL)
found = self._regex.fullmatch(subject)
def match(self, message: str, hint: str | None) -> dict | None:
segments = [self._message] + ([self._hint] if self._hint is not None else [])
texts = [message] + ([hint] if self._hint is not None else [])
found = _search(segments, texts, self._repeats)
if found is None:
return None
args: dict[str, Any] = {}
for name, value in found.groupdict().items():
for name, value in found.items():
if self._kinds.get(name) == "number":
if _CANONICAL_INT.fullmatch(value):
args[name] = int(value)
Expand Down Expand Up @@ -230,13 +326,12 @@ def classify(severity: str, message: str, hint: str | None = None) -> tuple[str,
``severity`` is ``"error"`` or ``"warning"``. A text no catalog template
renders gets ``PF-E0000`` / ``PF-W0000`` with its text as ``args``.
"""
subject = message if hint is None else message + _JOIN + hint
for matcher in _matchers().get(severity, ()):
if matcher.has_hint != (hint is not None):
continue
if not message.startswith(matcher.prefix) or not message.endswith(matcher.suffix):
continue
args = matcher.match(subject)
args = matcher.match(message, hint)
if args is not None:
return matcher.code, args
args = {"message": message}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ requires-python = ">=3.11"
authors = [
{ name = "PineForge", email = "luis@4pass.com.tw" },
]
# PineForge Source License 1.0 (source-available); see LICENSE.
# PineForge Source License 1.1 (source-available); see LICENSE.
license = { file = "LICENSE" }
readme = "README.md"
keywords = ["pinescript", "transpiler", "trading", "backtesting", "tradingview"]
Expand Down
9 changes: 6 additions & 3 deletions tests/test_array_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,15 +756,18 @@ def test_a_diamond_of_helpers_is_walked_once_per_helper():
def test_many_bindings_are_walked_once_each():
# Each binding's uses come from an index of the script's names: a walk of
# the rest of the script per binding took 57 s for these 4,000 (7 s now,
# 4 s for the same script binding array.copy(a) instead).
# 4 s for the same script binding array.copy(a) instead). The budget is
# CPU time, which a loaded host's queue does not stretch: alone on a Linux
# test host this takes 13-14 s; in full-suite xdist runs there (load 45-74)
# 27-33 s of CPU took 57-78 s of wall clock.
lines = ["a = array.from(close, open)", "float r = 0.0"]
for i in range(4000):
lines.append(f"p{i} = a[1]")
lines.append(f"r += p{i}.size()")
import time
started = time.monotonic()
started = time.process_time()
transpile(_script("\n".join(lines) + "\n"))
assert time.monotonic() - started < 40
assert time.process_time() - started < 60


def test_the_changing_methods_are_the_codegen_s_mutating_ones():
Expand Down
Loading
Loading