goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v actionlint >/dev/null 2>&1; then
echo "actionlint not found; install it to validate workflows (e.g., brew install actionlint)." >&2
exit 1
fi
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
if [ -n "$upstream" ]; then
changed=$(git diff --name-only --diff-filter=ACM "$upstream"...HEAD | grep -E '^.github/workflows/.*\.ya?ml$' || true)
else
changed=$(git diff --name-only --cached --diff-filter=ACM | grep -E '^.github/workflows/.*\.ya?ml$' || true)
fi
if [ -z "$changed" ]; then
exit 0
fi
actionlint $changed
+136
View File
@@ -0,0 +1,136 @@
# Contributing to ta4j
ta4j has been around for years and serves a large, diverse user base. Contributions are very welcome, but longterm maintainability takes precedence over quick wins. Please keep the sections below in mind before filing an issue or opening a PR.
## Principles
1. **Public APIs are contracts.** Moving classes between packages, renaming methods, or otherwise breaking binary/source compatibility forces every downstream user into a refactor. We only accept breaking changes when the value dramatically outweighs the disruption, and even then they must ship with deprecation shims and migration notes.
2. **Opinionated implementations belong outside the core.** ta4j aims to be widely applicable. Highly subjective “feature bundles” (e.g., metric dashboards, bespoke reporting formats, hard-coded broker behaviors) are better published as separate modules or example projects. Keep contributions focused on reusable primitives.
3. **Additive code beats churn.** New indicators, rules, serialization helpers, and documentation are great. Mechanical refactors (“just moved files around”) or stylistic changes with no behavioral impact rarely get merged.
4. **Tests tell the story.** Every change—bug fix or feature—needs focused tests demonstrating the behavior and guarding against regressions.
- **Run this before opening or updating a PR:** `mvn -B verify`
This matches the main CI path and keeps SpotBugs and JaCoCo advisory in the full contributor flow.
- **Use focused local quality loops when iterating:** `mvn -pl ta4j-core -am spotbugs:check` and `mvn -pl ta4j-core -am test jacoco:report jacoco:check`
These are intentionally strict for the module you are changing, so you can tighten one tool at a time before rerunning the full `mvn -B verify`.
- **Fix formatting and license headers when needed:** `mvn -B license:format formatter:format`
First-time contributors almost always hit this; run the formatter command locally before your final `mvn -B verify`.
## Contribution checklist
1. **Use Java 25+ and Maven 3.9+.** The build enforces these versions during Maven validation.
2. **Start with an issue** for anything non-trivial. Use it to confirm fit with the [Roadmap](https://ta4j.github.io/ta4j-wiki/Roadmap-and-Tasks.html) and to align on scope. [Search existing issues](https://github.com/ta4j/ta4j/issues?q=is%3Aissue) before opening a new one.
3. **Fork & branch** from `master`.
```bash
git clone https://github.com/<you>/ta4j.git
cd ta4j
git checkout -b feature/your-topic
```
4. **Implement + test.** Run the full build before pushing:
```bash
mvn -B clean license:format formatter:format test install
```
CI will fail if your changes are not formatted or lack the project license header. First-time contributors almost always hit this; run the command locally first.
Update `CHANGELOG.md` when you add, fix, or change behavior.
5. **Open the PR** against `ta4j/master`. Draft PRs are encouraged for early feedback. Prefer [well-formed commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
**Optional: Enable workflow linting hook** - If you're modifying GitHub Actions workflows, enable the pre-push hook to catch syntax errors early:
```bash
git config core.hooksPath .githooks
```
Then install `actionlint` (e.g., `brew install actionlint`). The hook will automatically lint any modified files under `.github/workflows/` before pushing.
## Contribution priorities
1. Items on the [Roadmap](https://ta4j.github.io/ta4j-wiki/Roadmap-and-Tasks.html).
2. Additive indicators/criteria/rules that do not change existing behavior.
3. Test coverage or documentation improvements.
4. Bug fixes (smaller, localized fixes are easier to land; large refactors should be discussed first).
5. API changes: only with clear justification, deprecation shims, and migration docs.
## Coding expectations
- Favor clarity over cleverness; write the code youd want to debug a year from now.
- Keep PRs scoped. If you find unrelated issues, file them or send separate PRs.
- Every new public class/method needs Javadoc with `@since <version>`.
- Use primitives for indicator parameters (e.g., `int timeFrame`). Convert to `Num` inside using `series.numFactory()`.
- Do not cache `Num` instances globally—always obtain them from the relevant factory.
## Indicator contributions
Open an issue to discuss the new indicator first. Every indicator must ship with matching tests:
- `src/main/java/org/ta4j/core/indicators/.../NewIndicator.java`
- `src/test/java/org/ta4j/core/indicators/.../NewIndicatorTest.java`
## Tagged test workflows
Regular PR and push CI skips test tags configured by `ta4j.excludedTestTags`.
Run tagged suites manually from GitHub Actions, or locally with:
- `xvfb-run mvn -B test -Dgroups=integration -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay`
- `xvfb-run mvn -B test -Dgroups=benchmark -Dta4j.excludedTestTags= -Dta4j.runBenchmarks=true`
- `xvfb-run mvn -B test -Dgroups=analysis-demo -Dta4j.excludedTestTags=elliott-macro-cycle-replay -Dta4j.analysisDemoInstrument=coinbase:BTC-USD -Dta4j.analysisDemoOutputDir=target/analysis-demos/elliott-wave`
- `xvfb-run mvn -B test -Dgroups=elliott-macro-cycle-replay -Dta4j.excludedTestTags= -Dtest=ElliottWaveMacroCycleDetectorTest`
These examples match the Linux GitHub Actions runners. On macOS, use XQuartz or
run the Maven command without `xvfb-run` when your local display can satisfy
UI-dependent tests. On Windows, use WSL2, a CI runner, or an equivalent X server.
The dedicated workflows are:
- `Run Integration Tagged Tests` (`.github/workflows/test-tag-integration.yml`)
- `Run Benchmark Tagged Tests` (`.github/workflows/test-tag-benchmark.yml`)
- `Run Analysis Demo Tagged Tests` (`.github/workflows/test-tag-analysis-demo.yml`)
- `Run Elliott Macro Cycle Replay Tagged Tests` (`.github/workflows/test-tag-elliott-macro-cycle-replay.yml`)
Scheduled runs are opt-in per tag. Set `TA4J_TAGGED_TEST_<TAG>_SCHEDULE_ENABLED=true`
and `TA4J_TAGGED_TEST_<TAG>_SCHEDULE_SLOT=daily`, `weekly`, or `monthly`.
Unset variables leave scheduled runs disabled, while manual workflow dispatches run
regardless of the schedule variables. The `elliott-macro-cycle-replay` workflow
is manual-only and requires a self-hosted runner labeled `ta4j-macro-cycle-replay`.
The `analysis-demo` tag is for examples that produce analysis reports and must
be the only JUnit tag on each tagged test or class.
Its workflow defaults to `coinbase:BTC-USD`, accepts provider-qualified manual
inputs such as `coinbase:ETH-USD` or `coinbase:ETH/USD`, and uploads generated
JSON, charts, and cached provider responses from `target/analysis-demos/**`.
Version 1 supports Coinbase instruments only. For scheduled analysis-demo runs,
`weekly` is the intended slot; use
`TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_ENABLED=true` with
`TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT=weekly`, and set
`TA4J_ANALYSIS_DEMO_INSTRUMENT` to override the scheduled instrument.
## API lifecycle and @since policy
- Add `@since <version>` to every newly introduced class and API member, using the introducing release version without `-SNAPSHOT` (for example `0.22.4`, not `0.22.4-SNAPSHOT`).
- This gives us a reliable introduction point for deprecation tracking and lifecycle automation.
- New API is considered volatile for the next 5 minor releases after it is introduced.
- Example: API added in `0.22.4` may still change incompatibly, or be removed, through `0.27.4` (inclusive).
- Treat this window as experimental/beta and avoid production-critical dependency unless you explicitly accept migration risk.
## Branching model
Enhancements, new features and fixes should be pushed to a [fork](https://help.github.com/articles/fork-a-repo/) of the master branch. Once completed they will be merged with the master branch during a [pull request](https://help.github.com/articles/about-pull-requests/). GitHub actions are configured to run the tests, validate the licence header and source code format. After the PR has been merged a new SNAPSHOT will be deployed.
This development process is similar to [github flow](https://docs.github.com/en/get-started/quickstart/github-flow).
* **Only the content of the master branch is going to become a release.**
* **There is no release branch nor a mandatory develop branch**
### Release Process
For maintainers, the release process is fully automated using GitHub Actions workflows. The process includes:
- **Automated release scheduling**: AI-powered scheduler analyzes changes and determines version bumps (patch/minor/major)
- **Two-phase release workflow**: `prepare-release.yml` prepares release commits and PRs, `publish-release.yml` handles tagging and deployment
- **Release health monitoring**: Automated checks for tag reachability, version drift, and stale release PRs
- **GitHub Release automation**: Automatic creation of GitHub Releases with artifacts and release notes
For detailed information about the release process, see [RELEASE_PROCESS.md](../RELEASE_PROCESS.md) in the main repository.
## Quick tips
- Use `series.getBeginIndex()` instead of `0` when iterating a `BarSeries`.
- Remember the difference between `DecimalNum.min(...)` and `DecimalNum.minus(...)`.
- When in doubt, ask. Its easier (and faster) to course-correct early than to rework a large PR later.
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
**Describe the bug**
A clear and concise description of what the bug is and what version you are using. If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here. Please ensure you call out the version you are on.
+24
View File
@@ -0,0 +1,24 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen. If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+14
View File
@@ -0,0 +1,14 @@
---
name: General question
about: A general question about how to use ta4j
title: ''
labels: question
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
+10
View File
@@ -0,0 +1,10 @@
Fixes #.
Changes proposed in this pull request:
-
-
-
- [ ] I have run `mvn -B clean license:format formatter:format test install` to format and test my changes per the [contributing guidelines](.github/CONTRIBUTING.md)
- [ ] I have added an entry with applicable ticket number(s) to the appropriate unreleased section of `CHANGELOG.md`
+24
View File
@@ -0,0 +1,24 @@
self-hosted-runner:
# Labels of self-hosted runner in array of strings.
labels:
- ta4j-macro-cycle-replay
# Configuration variables in array of strings defined in your repository or
# organization. `null` means disabling configuration variables check.
# Empty array means no configuration variable is allowed.
config-variables: null
# Configuration for file paths. The keys are glob patterns to match to file
# paths relative to the repository root. The values are the configurations for
# the file paths. Note that the path separator is always '/'.
# The following configurations are available.
#
# "ignore" is an array of regular expression patterns. Matched error messages
# are ignored. This is similar to the "-ignore" command line option.
paths:
.github/workflows/**/*.yml:
ignore:
- 'shellcheck reported issue in this script: SC[0-9]+:(info|warning|style):'
.github/workflows/**/*.yaml:
ignore:
- 'shellcheck reported issue in this script: SC[0-9]+:(info|warning|style):'
+27
View File
@@ -0,0 +1,27 @@
name: Lint GitHub Actions Workflows
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
branches:
- master
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run actionlint
uses: rhysd/actionlint@v1.7.12
+39
View File
@@ -0,0 +1,39 @@
name: Check License Headers
permissions:
contents: read
on:
pull_request:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Check License Headers
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Check license headers
run: xvfb-run mvn -B license:check
+323
View File
@@ -0,0 +1,323 @@
name: Create GitHub Release
on:
push:
tags:
- "*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish (e.g. 0.22.0)"
required: true
type: string
dryRun:
description: "Run in dry-run mode to preview release creation without publishing."
required: false
default: true
type: boolean
concurrency:
group: github-release-${{ github.repository }}
permissions:
contents: write
jobs:
create_release:
name: Create GitHub Release
runs-on: ubuntu-latest
env:
GH_TA4J_REPO_TOKEN: ${{ secrets.GH_TA4J_REPO_TOKEN }}
steps:
- name: Determine checkout ref
id: checkout_ref
run: |
INPUT_TAG="${{ github.event.inputs.tag }}"
if [[ -n "$INPUT_TAG" ]]; then
# For workflow_dispatch, construct tag ref from input
if [[ "$INPUT_TAG" == refs/tags/* ]]; then
INPUT_TAG="${INPUT_TAG#refs/tags/}"
fi
CHECKOUT_REF="refs/tags/${INPUT_TAG}"
echo "tag=${INPUT_TAG}" >> $GITHUB_OUTPUT
else
# For tag push events, use the ref directly
CHECKOUT_REF="${GITHUB_REF}"
fi
echo "ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
- name: Verify GH_TA4J_REPO_TOKEN present
run: |
if [[ -z "${GH_TA4J_REPO_TOKEN:-}" ]]; then
echo "::error::GH_TA4J_REPO_TOKEN is required to create GitHub Releases."
exit 1
fi
- name: Checkout full history
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.checkout_ref.outputs.ref }}
token: ${{ env.GH_TA4J_REPO_TOKEN }}
- name: Checkout workflow support files
uses: actions/checkout@v6
with:
fetch-depth: 1
ref: ${{ github.sha }}
path: workflow-support
token: ${{ env.GH_TA4J_REPO_TOKEN }}
- name: Normalize dry run input
id: dry_run
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
raw="${{ github.event.inputs.dryRun }}"
if [ -z "$raw" ]; then
raw=true
fi
normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then
dry_run=false
else
dry_run=true
fi
else
raw=""
dry_run=false
fi
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
# ------------------------------------------------------------
# Determine Tag + Validate Release Notes File
# ------------------------------------------------------------
- name: Determine Version
id: version
run: |
echo "::group::Resolve GitHub Release tag and notes"
INPUT_TAG="${{ steps.checkout_ref.outputs.tag }}"
if [[ -n "$INPUT_TAG" ]]; then
TAG="$INPUT_TAG"
else
if [[ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]]; then
echo "Error: 'tag' input is required for workflow_dispatch runs." >&2
exit 1
fi
TAG="${GITHUB_REF##*/}"
fi
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::GitHub Release tags must be bare SemVer major.minor.patch values. Got: $TAG"
exit 1
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
NOTES_FILE="release/${TAG}.md"
if [[ ! -f "$NOTES_FILE" ]]; then
echo "Release notes file $NOTES_FILE not found" >&2
exit 1
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
echo "::endgroup::"
- name: Preflight release permissions (dry run)
if: steps.dry_run.outputs.dryRun == 'true'
id: token_preflight
env:
GH_TA4J_REPO_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }}
run: |
set -euo pipefail
python - <<'PY'
import json
import os
import sys
import urllib.request
owner = os.environ.get("GITHUB_REPOSITORY_OWNER")
repo = os.environ.get("GITHUB_REPOSITORY", "").split("/", 1)[-1]
if not owner or not repo:
print("::error::Unable to resolve repository context for permission preflight.")
sys.exit(1)
def viewer_permission(token: str) -> str:
query = {
"query": "query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){viewerPermission}}",
"variables": {"owner": owner, "repo": repo},
}
req = urllib.request.Request(
"https://api.github.com/graphql",
data=json.dumps(query).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "ta4j-release-preflight",
},
)
with urllib.request.urlopen(req) as resp:
data = json.load(resp)
if "errors" in data:
raise RuntimeError(data["errors"])
return data["data"]["repository"]["viewerPermission"]
def has_write(permission: str) -> bool:
return permission in ("WRITE", "MAINTAIN", "ADMIN")
def check_token(token, label):
if not token:
print(f"{label}:missing")
return None
try:
permission = viewer_permission(token)
print(f"{label}:{permission}")
return permission
except Exception as exc:
print(f"::warning::{label} permission check failed: {exc}")
return None
pat = os.environ.get("GH_TA4J_REPO_TOKEN", "")
pat_perm = check_token(pat, "GH_TA4J_REPO_TOKEN")
if not (pat_perm and has_write(pat_perm)):
print("::error::GH_TA4J_REPO_TOKEN lacks write permission to create a release.")
sys.exit(1)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"repo_token_permission={pat_perm}\n")
PY
# ------------------------------------------------------------
# Prepare Release Notes Body
# ------------------------------------------------------------
# ------------------------------------------------------------
# Build artifacts: core, examples, ALL modules
# Produces:
# - *.jar
# - *-javadoc.jar
# - *-sources.jar
# ------------------------------------------------------------
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build all module artifacts
shell: bash
run: |
set -euo pipefail
echo "::group::Build GitHub Release artifacts"
max_attempts=3
attempt=1
while true; do
echo "audit:release_artifact_build attempt=${attempt}/${max_attempts}"
if mvn -B -DskipTests -Dgpg.skip=true -Pproduction-release package 2>&1 | tee release-artifact-build.log; then
break
fi
if ((attempt >= max_attempts)); then
echo "::error::Release artifact build failed after ${max_attempts} attempts."
exit 1
fi
backoff_seconds=$((attempt * 15))
echo "::warning::Release artifact build failed on attempt ${attempt}. Retrying in ${backoff_seconds}s."
sleep "${backoff_seconds}"
attempt=$((attempt + 1))
done
echo "::endgroup::"
- name: Validate release artifacts
id: artifacts
run: |
set -euo pipefail
echo "::group::Validate exact GitHub Release artifact manifest"
python3 workflow-support/scripts/release/release_helpers.py artifact-manifest \
--version "${{ steps.version.outputs.tag }}" \
--output artifact-manifest.txt \
--github-output "$GITHUB_OUTPUT" \
--strict
echo "::endgroup::"
- name: Dry-run summary
if: steps.dry_run.outputs.dryRun == 'true'
run: |
echo "DRY RUN ENABLED: build completed; release publish skipped."
echo "Tag: ${{ steps.version.outputs.tag }}"
echo "Release notes: ${{ steps.version.outputs.notes_file }}"
echo "GH_TA4J_REPO_TOKEN permission: ${{ steps.token_preflight.outputs.repo_token_permission }}"
echo "Artifacts:"
files="${{ steps.artifacts.outputs.files }}"
if [[ -z "${files}" ]]; then
echo " (none found)"
else
while IFS= read -r file; do
echo " - ${file#./}"
done <<< "${files}"
fi
# ------------------------------------------------------------
# Upload full artifact set to GitHub Release
# ------------------------------------------------------------
- name: Create GitHub Release
if: steps.dry_run.outputs.dryRun != 'true'
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
body_path: ${{ steps.version.outputs.notes_file }}
files: ${{ steps.artifacts.outputs.files }}
env:
GITHUB_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }}
- name: GitHub Release summary
if: always()
env:
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
TAG: ${{ steps.version.outputs.tag }}
NOTES_FILE: ${{ steps.version.outputs.notes_file }}
run: |
if [ "${DRY_RUN:-false}" = "true" ]; then
mutation_plan="would create/update the GitHub Release and upload artifacts for ${TAG:-unknown}"
rerun_guidance="rerun github-release.yml with dryRun=false for this tag, or let the tag push path run"
else
mutation_plan="completed GitHub Release publication"
rerun_guidance="not needed for this non-dry-run GitHub Release run"
fi
cat > github-release-audit.json <<EOF
{
"dryRun": "${DRY_RUN:-}",
"tag": "${TAG:-}",
"notesFile": "${NOTES_FILE:-}",
"artifactManifest": "artifact-manifest.txt",
"mutationPlan": "${mutation_plan}",
"rerunGuidance": "${rerun_guidance}",
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
}
EOF
{
echo "## GitHub Release"
echo ""
echo "- dry run: ${DRY_RUN:-unknown}"
echo "- tag: ${TAG:-unknown}"
echo "- notes file: ${NOTES_FILE:-unknown}"
echo "- artifact manifest: artifact-manifest.txt"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload GitHub Release audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: github-release-audit-${{ github.run_id }}
path: |
github-release-audit.json
artifact-manifest.txt
release-artifact-build.log
if-no-files-found: ignore
retention-days: 14
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
name: Release Merge Freeze
on:
pull_request:
branches:
- master
types:
- opened
- reopened
- synchronize
- ready_for_review
- edited
- labeled
- unlabeled
- closed
env:
RELEASE_FREEZE_AUTHORS: ${{ vars.RELEASE_OWNER || 'TheCookieLab' }}
permissions:
pull-requests: write
issues: write
jobs:
enforce-release-freeze:
name: Enforce release freeze on master merges
runs-on: ubuntu-latest
steps:
- name: Block non-release merges while release PR is open
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr = context.payload.pull_request;
const baseBranch = pr?.base?.ref || context.payload.repository?.default_branch;
const noticeMarker = "<!-- ta4j:release-freeze-notice -->";
const releaseLabel = "release";
const action = context.payload.action;
const trustedAuthors = new Set(
(process.env.RELEASE_FREEZE_AUTHORS || "TheCookieLab")
.split(",")
.map((author) => (author || "").trim().toLowerCase())
.filter((author) => author.length > 0),
);
if (!pr || !baseBranch) {
core.info(`Skipping release freeze check: missing pull request context or base branch.`);
return;
}
const hasLabel = (labels, name) =>
(labels || []).some((label) => label.name === name);
const isReleaseBranch = (candidate) =>
(candidate?.head?.ref || "").startsWith("release/");
const isTrustedReleaseAuthor = (candidate) =>
trustedAuthors.has((candidate?.user?.login || "").toLowerCase());
const isTrustedReleasePr = (candidate) =>
hasLabel(candidate.labels, releaseLabel) &&
isTrustedReleaseAuthor(candidate) &&
isReleaseBranch(candidate);
const buildReleaseSummary = (releasePr) =>
releasePr.map((candidate) => `- [#${candidate.number}](${candidate.html_url})`).join("\n");
const findNoticeComment = async (issueNumber) => {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100
});
return comments.find((comment) =>
comment.user?.type === "Bot" && comment.body && comment.body.includes(noticeMarker)
);
};
const removeNoticeComment = async (issueNumber) => {
const notice = await findNoticeComment(issueNumber);
if (!notice) {
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: notice.id
});
};
const upsertNoticeComment = async (issueNumber, releasePrLinks) => {
const existing = await findNoticeComment(issueNumber);
const body = [
`${noticeMarker}`,
"⚠️ Release Freeze is active.",
"",
`One or more trusted release PRs (label \`release\`, branch \`release/*\`) are currently open against \`${baseBranch}\`, so non-release merges are blocked until these PRs merge or close:`,
releasePrLinks,
"",
"Please merge/close the active release PRs first."
].join("\n");
if (existing && existing.body === body) {
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body
});
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body
});
};
if (!pr || pr.base.ref !== baseBranch) {
core.info(`Skipping release freeze check for PR #${pr?.number}. Base is ${pr?.base?.ref ?? "unknown"}, expected ${baseBranch}.`);
return;
}
const isReleasePr = isTrustedReleasePr(pr);
const openPrs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
base: baseBranch
});
const releasePrs = openPrs.filter((candidate) => isTrustedReleasePr(candidate));
const openNonReleasePrs = openPrs.filter((candidate) =>
!hasLabel(candidate.labels, releaseLabel)
);
const releasePrLinks = buildReleaseSummary(releasePrs);
for (const candidate of openNonReleasePrs) {
try {
if (releasePrs.length > 0) {
await upsertNoticeComment(candidate.number, releasePrLinks);
} else {
await removeNoticeComment(candidate.number);
}
} catch (error) {
core.warning(`Failed to sync release-freeze notice for PR #${candidate.number}: ${error.message}`);
}
}
if (releasePrs.length === 0) {
core.info("No open release PRs detected; merge allowed.");
return;
}
if (isReleasePr) {
core.info(`PR #${pr.number} is a trusted release PR; freeze check is informational only.`);
return;
}
if (action === "closed") {
core.info(`Skipping merge block for closed PR #${pr.number}; freeze notice cleanup already handled.`);
return;
}
core.setFailed(`Release freeze is active on ${baseBranch}. Merge blocked while release PRs are open: ${releasePrLinks}. Close or merge the release PR(s) before merging this PR.`);
+628
View File
@@ -0,0 +1,628 @@
name: Release Health Check
concurrency:
group: release-health-${{ github.repository }}
on:
push:
branches:
- master
schedule:
- cron: "0 9 * * *"
workflow_run:
workflows: ["Publish Release to Maven Central", "Publish Snapshot to Maven Central"]
types:
- completed
workflow_dispatch:
inputs:
staleDays:
description: "Days before a release PR is considered stale."
required: false
default: "7"
type: string
dryRun:
description: "Run release health in dry-run mode (audit only; no discussion mutation)."
required: false
default: true
type: boolean
permissions:
contents: read
pull-requests: read
discussions: write
jobs:
health:
name: Release health checks
runs-on: ubuntu-latest
if: |
github.event_name != 'workflow_run' ||
(
github.event.workflow_run.head_repository.full_name == github.repository
)
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Normalize dry run input
id: dry_run
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
raw="${{ github.event.inputs.dryRun }}"
if [ -z "$raw" ]; then
raw=true
fi
normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then
dry_run=false
else
dry_run=true
fi
else
raw=""
dry_run=false
fi
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
- name: Fetch tags
run: git fetch --tags
- name: Resolve default branch
id: default_branch
run: |
set -euo pipefail
echo "::group::Resolve default branch"
branch="${{ github.event.repository.default_branch }}"
if [ -z "$branch" ]; then
branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || true)
fi
if [ -z "$branch" ]; then
branch=$(git remote show origin 2>/dev/null | awk '/HEAD branch/ {print $NF}' || true)
fi
if [ -z "$branch" ]; then
echo "Default branch not provided by event or git." >&2
exit 1
fi
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "audit:default_branch=$branch"
echo "::endgroup::"
- name: Collect tag and version state
id: state
env:
DEFAULT_BRANCH: ${{ steps.default_branch.outputs.branch }}
run: |
set -euo pipefail
echo "::group::Collect release tag and version state"
git fetch origin "$DEFAULT_BRANCH" --tags
target_ref="origin/${DEFAULT_BRANCH}"
tag_state_file=$(mktemp)
scripts/resolve-release-tags.sh "$target_ref" > "$tag_state_file"
latest_tag="none"
while IFS='=' read -r key value; do
case "$key" in
latest_tag) latest_tag="$value" ;;
esac
done < "$tag_state_file"
cat "$tag_state_file" >> "$GITHUB_OUTPUT"
cp "$tag_state_file" tag-resolution.txt
rm -f "$tag_state_file"
pom_version=$(python3 - <<'PY'
import sys
import xml.etree.ElementTree as ET
def find_version(path):
tree = ET.parse(path)
root = tree.getroot()
def find_first(elem):
if elem is None:
return None
for child in elem:
if child.tag.endswith("version"):
text = (child.text or "").strip()
if text:
return text
return None
return find_first(root) or find_first(root.find("./{*}parent"))
ver = find_version("pom.xml")
if not ver:
sys.exit(1)
print(ver)
PY
) || { echo "Could not read pom.xml version" >&2; exit 1; }
pom_snapshot=false
pom_base="$pom_version"
if [[ "$pom_version" == *-SNAPSHOT ]]; then
pom_snapshot=true
pom_base="${pom_version%-SNAPSHOT}"
fi
snapshot_ok="n/a"
if [ "$latest_tag" != "none" ]; then
latest_base="${latest_tag#v}"
if [ "$pom_snapshot" != "true" ]; then
snapshot_ok=false
else
highest=$(printf '%s\n' "$latest_base" "$pom_base" | sort -V | tail -n1)
if [ "$highest" = "$pom_base" ] && [ "$pom_base" != "$latest_base" ]; then
snapshot_ok=true
else
snapshot_ok=false
fi
fi
fi
notes_present="n/a"
if [ "$latest_tag" != "none" ]; then
if git cat-file -e "${latest_tag}:release/${latest_tag}.md" 2>/dev/null; then
notes_present=true
elif git cat-file -e "${latest_tag}:release/${latest_tag#v}.md" 2>/dev/null; then
notes_present=true
else
notes_present=false
fi
fi
echo "pom_version=$pom_version" >> $GITHUB_OUTPUT
echo "pom_base=$pom_base" >> $GITHUB_OUTPUT
echo "pom_snapshot=$pom_snapshot" >> $GITHUB_OUTPUT
echo "snapshot_ok=$snapshot_ok" >> $GITHUB_OUTPUT
echo "release_notes_present=$notes_present" >> $GITHUB_OUTPUT
echo "::endgroup::"
- name: Resolve snapshot publication state
id: snapshot_publication
env:
POM_VERSION: ${{ steps.state.outputs.pom_version }}
run: |
set -euo pipefail
python3 scripts/release/release_helpers.py snapshot-publication \
--version "${POM_VERSION}" \
--output snapshot-publication.json \
--github-output "$GITHUB_OUTPUT"
- name: Resolve snapshot publication policy
id: snapshot_policy
env:
HEALTH_EVENT_NAME: ${{ github.event_name }}
HEALTH_TRIGGER_WORKFLOW: ${{ github.event.workflow_run.name }}
run: |
set -euo pipefail
python3 scripts/release/release_helpers.py snapshot-publication-policy \
--event-name "${HEALTH_EVENT_NAME}" \
--workflow-name "${HEALTH_TRIGGER_WORKFLOW}" \
--output snapshot-publication-policy.json \
--github-output "$GITHUB_OUTPUT"
- name: Check for stale release PRs
id: stale_prs
uses: actions/github-script@v9
env:
INPUT_STALE_DAYS: ${{ github.event.inputs.staleDays }}
VAR_STALE_DAYS: ${{ vars.RELEASE_PR_STALE_DAYS }}
with:
github-token: ${{ github.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const inputDays = process.env.INPUT_STALE_DAYS;
const varDays = process.env.VAR_STALE_DAYS;
const staleDays = Number(inputDays || varDays || "7");
if (!Number.isFinite(staleDays) || staleDays <= 0) {
throw new Error(`Invalid stale day threshold: ${inputDays || varDays}`);
}
const cutoff = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100
});
const stale = prs.filter((pr) => {
const labels = pr.labels || [];
const hasReleaseLabel = labels.some((label) => label.name === "release");
if (!hasReleaseLabel) {
return false;
}
return new Date(pr.created_at) < cutoff;
});
const lines = stale.map((pr) => {
const ageDays = Math.floor((Date.now() - new Date(pr.created_at)) / (24 * 60 * 60 * 1000));
return `#${pr.number} ${pr.title} (${ageDays} days old)`;
});
core.setOutput("stale_days", staleDays.toString());
core.setOutput("stale_count", stale.length.toString());
core.setOutput("stale_list", lines.join("\n") || "(none)");
- name: Build health summary
id: summary
if: always()
env:
DEFAULT_BRANCH: ${{ steps.default_branch.outputs.branch }}
LATEST_TAG: ${{ steps.state.outputs.latest_tag }}
LAST_REACHABLE_TAG: ${{ steps.state.outputs.last_reachable_tag }}
LAST_FIRST_PARENT_TAG: ${{ steps.state.outputs.last_first_parent_tag }}
LATEST_TAG_REACHABLE: ${{ steps.state.outputs.latest_tag_reachable }}
POM_VERSION: ${{ steps.state.outputs.pom_version }}
POM_BASE: ${{ steps.state.outputs.pom_base }}
POM_SNAPSHOT: ${{ steps.state.outputs.pom_snapshot }}
SNAPSHOT_OK: ${{ steps.state.outputs.snapshot_ok }}
SNAPSHOT_PUBLICATION: ${{ steps.snapshot_publication.outputs.snapshot_publication }}
SNAPSHOT_PUBLICATION_LATEST: ${{ steps.snapshot_publication.outputs.snapshot_publication_latest }}
SNAPSHOT_PUBLICATION_LAST_UPDATED: ${{ steps.snapshot_publication.outputs.snapshot_publication_last_updated }}
SNAPSHOT_PUBLICATION_SOURCE: ${{ steps.snapshot_publication.outputs.snapshot_publication_source }}
SNAPSHOT_PUBLICATION_ENFORCED: ${{ steps.snapshot_policy.outputs.snapshot_publication_enforced }}
SNAPSHOT_PUBLICATION_PENDING_REASON: ${{ steps.snapshot_policy.outputs.snapshot_publication_pending_reason }}
NOTES_PRESENT: ${{ steps.state.outputs.release_notes_present }}
STALE_DAYS: ${{ steps.stale_prs.outputs.stale_days }}
STALE_COUNT: ${{ steps.stale_prs.outputs.stale_count }}
STALE_LIST: ${{ steps.stale_prs.outputs.stale_list }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
HEALTH_EVENT_NAME: ${{ github.event_name }}
HEALTH_TRIGGER_WORKFLOW: ${{ github.event.workflow_run.name }}
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
run: |
set -euo pipefail
if [ -z "${DEFAULT_BRANCH:-}" ]; then
DEFAULT_BRANCH="(unknown)"
fi
if [ -z "${LATEST_TAG:-}" ]; then
LATEST_TAG="(unknown)"
fi
if [ -z "${LAST_REACHABLE_TAG:-}" ]; then
LAST_REACHABLE_TAG="(unknown)"
fi
if [ -z "${LAST_FIRST_PARENT_TAG:-}" ]; then
LAST_FIRST_PARENT_TAG="(unknown)"
fi
if [ -z "${LATEST_TAG_REACHABLE:-}" ]; then
LATEST_TAG_REACHABLE="(unknown)"
fi
if [ -z "${POM_VERSION:-}" ]; then
POM_VERSION="(unknown)"
fi
if [ -z "${POM_BASE:-}" ]; then
POM_BASE="(unknown)"
fi
if [ -z "${POM_SNAPSHOT:-}" ]; then
POM_SNAPSHOT="(unknown)"
fi
if [ -z "${SNAPSHOT_OK:-}" ]; then
SNAPSHOT_OK="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION:-}" ]; then
SNAPSHOT_PUBLICATION="unknown"
fi
if [ -z "${SNAPSHOT_PUBLICATION_LATEST:-}" ]; then
SNAPSHOT_PUBLICATION_LATEST="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_LAST_UPDATED:-}" ]; then
SNAPSHOT_PUBLICATION_LAST_UPDATED="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_SOURCE:-}" ]; then
SNAPSHOT_PUBLICATION_SOURCE="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_ENFORCED:-}" ]; then
SNAPSHOT_PUBLICATION_ENFORCED="false"
fi
if [ -z "${SNAPSHOT_PUBLICATION_PENDING_REASON:-}" ]; then
SNAPSHOT_PUBLICATION_PENDING_REASON="(none)"
fi
if [ -z "${NOTES_PRESENT:-}" ]; then
NOTES_PRESENT="(unknown)"
fi
if [ -z "${STALE_DAYS:-}" ]; then
STALE_DAYS="(unknown)"
fi
if [ -z "${STALE_COUNT:-}" ]; then
STALE_COUNT="0"
fi
if [ -z "${STALE_LIST:-}" ]; then
STALE_LIST="(none)"
fi
if [ -z "${HEALTH_EVENT_NAME:-}" ]; then
HEALTH_EVENT_NAME="(unknown)"
fi
if [ -z "${HEALTH_TRIGGER_WORKFLOW:-}" ]; then
HEALTH_TRIGGER_WORKFLOW="(none)"
fi
if [ -z "${DRY_RUN:-}" ]; then
DRY_RUN="false"
fi
effective_snapshot_publication="${SNAPSHOT_PUBLICATION}"
snapshot_publication_note="(none)"
if [ "${SNAPSHOT_PUBLICATION_ENFORCED}" != "true" ] && [[ "${SNAPSHOT_PUBLICATION}" == "false" || "${SNAPSHOT_PUBLICATION}" == "unknown" ]]; then
effective_snapshot_publication="pending"
snapshot_publication_note="${SNAPSHOT_PUBLICATION_PENDING_REASON}"
fi
drift=false
reasons=()
if [ "$LATEST_TAG_REACHABLE" = "false" ]; then
drift=true
reasons+=("latest tag not reachable from ${DEFAULT_BRANCH}")
fi
if [ "$SNAPSHOT_OK" = "false" ]; then
drift=true
reasons+=("pom.xml snapshot version not ahead of latest tag")
fi
if [ "$effective_snapshot_publication" = "false" ]; then
drift=true
reasons+=("current snapshot version not published to Maven snapshot repository")
fi
if [ "$effective_snapshot_publication" = "unknown" ]; then
drift=true
reasons+=("snapshot publication state could not be verified")
fi
if [ "$NOTES_PRESENT" = "false" ]; then
drift=true
reasons+=("missing release notes for latest tag")
fi
if [ "${STALE_COUNT:-0}" -gt 0 ]; then
drift=true
reasons+=("stale release PRs detected")
fi
if [ "${#reasons[@]}" -eq 0 ]; then
reason_text="(none)"
else
reason_text=$(printf '%s\n' "${reasons[@]}")
fi
if [ "$drift" = "true" ]; then
status="FAIL"
else
status="OK"
fi
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
timestamp=$(date '+%-I:%M:%S %p on %-m.%-d.%Y')
notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}"
if [ "$DRY_RUN" = "true" ]; then
marker_run="dry-run"
mutation_plan="would post the release-health summary to the Release Scheduler discussion"
rerun_guidance="rerun release-health.yml with dryRun=false, or let schedule/push/workflow-run triggers publish the health post"
else
marker_run="real"
mutation_plan="posts the latest release-health summary to the Release Scheduler discussion"
rerun_guidance="not needed for this non-dry-run health publication"
fi
cat > notification-body.txt <<EOF
<!-- ta4j:post-type=release-health;run=${marker_run} -->
@${notify_user}
**Release Health Check ${status} at ${timestamp}**
- Repository: ${GITHUB_REPOSITORY}
- Run: ${run_url}
- Default branch: ${DEFAULT_BRANCH}
- Event: ${HEALTH_EVENT_NAME}
- Upstream workflow: ${HEALTH_TRIGGER_WORKFLOW}
- Dry run: ${DRY_RUN}
Tag Health:
- latest tag: ${LATEST_TAG}
- last reachable tag: ${LAST_REACHABLE_TAG}
- last first-parent tag: ${LAST_FIRST_PARENT_TAG}
- latest tag reachable: ${LATEST_TAG_REACHABLE}
- release notes present: ${NOTES_PRESENT}
Version Health:
- pom.xml version: ${POM_VERSION}
- pom.xml base: ${POM_BASE}
- pom.xml snapshot: ${POM_SNAPSHOT}
- snapshot ahead of latest tag: ${SNAPSHOT_OK}
- snapshot publication enforced: ${SNAPSHOT_PUBLICATION_ENFORCED}
- snapshot published: ${effective_snapshot_publication}
- snapshot publication note: ${snapshot_publication_note}
- snapshot metadata latest: ${SNAPSHOT_PUBLICATION_LATEST}
- snapshot metadata last updated: ${SNAPSHOT_PUBLICATION_LAST_UPDATED}
Stale Release PRs (>${STALE_DAYS} days):
- count: ${STALE_COUNT}
${STALE_LIST}
Drift reasons:
${reason_text}
Dry-Run / Mutation Plan:
- mutation plan: ${mutation_plan}
- rerun guidance: ${rerun_guidance}
EOF
echo "drift_found=$drift" >> $GITHUB_OUTPUT
cat > release-health-audit.json <<EOF
{
"status": "${status}",
"drift": "${drift}",
"dryRun": "${DRY_RUN}",
"defaultBranch": "${DEFAULT_BRANCH}",
"latestTag": "${LATEST_TAG}",
"lastReachableTag": "${LAST_REACHABLE_TAG}",
"lastFirstParentTag": "${LAST_FIRST_PARENT_TAG}",
"latestTagReachable": "${LATEST_TAG_REACHABLE}",
"pomVersion": "${POM_VERSION}",
"pomBase": "${POM_BASE}",
"pomSnapshot": "${POM_SNAPSHOT}",
"snapshotOk": "${SNAPSHOT_OK}",
"snapshotPublicationEnforced": "${SNAPSHOT_PUBLICATION_ENFORCED}",
"snapshotPublished": "${effective_snapshot_publication}",
"snapshotPublicationNote": "${snapshot_publication_note}",
"snapshotMetadataLatest": "${SNAPSHOT_PUBLICATION_LATEST}",
"snapshotMetadataLastUpdated": "${SNAPSHOT_PUBLICATION_LAST_UPDATED}",
"snapshotMetadataSource": "${SNAPSHOT_PUBLICATION_SOURCE}",
"releaseNotesPresent": "${NOTES_PRESENT}",
"staleReleasePrCount": "${STALE_COUNT}",
"mutationPlan": "${mutation_plan}",
"rerunGuidance": "${rerun_guidance}",
"runUrl": "${run_url}"
}
EOF
{
echo "## Release Health"
echo ""
echo "- status: ${status}"
echo "- drift: ${drift}"
echo "- dry run: ${DRY_RUN}"
echo "- latest tag: ${LATEST_TAG}"
echo "- last reachable tag: ${LAST_REACHABLE_TAG}"
echo "- latest tag reachable: ${LATEST_TAG_REACHABLE}"
echo "- pom version: ${POM_VERSION}"
echo "- snapshot ahead of latest tag: ${SNAPSHOT_OK}"
echo "- snapshot publication enforced: ${SNAPSHOT_PUBLICATION_ENFORCED}"
echo "- snapshot published: ${effective_snapshot_publication}"
echo "- stale release PR count: ${STALE_COUNT}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release health audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-health-audit-${{ github.run_id }}
path: |
tag-resolution.txt
snapshot-publication.json
snapshot-publication-policy.json
release-health-audit.json
notification-body.txt
if-no-files-found: ignore
retention-days: 14
- name: Post to Release Scheduler discussion
if: always() && steps.dry_run.outputs.dryRun != 'true'
uses: actions/github-script@v9
env:
RELEASE_SCHEDULER_DISCUSSION_NUMBER: ${{ vars.RELEASE_SCHEDULER_DISCUSSION_NUMBER }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
with:
github-token: ${{ github.token }}
script: |
const fs = require("fs");
const owner = context.repo.owner;
const repo = context.repo.repo;
const notifyUser = process.env.RELEASE_NOTIFY_USER || "TheCookieLab";
const runUrl = process.env.GITHUB_RUN_ID
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: "(unknown)";
let body;
if (fs.existsSync("notification-body.txt")) {
body = fs.readFileSync("notification-body.txt", "utf8");
} else {
core.warning("notification-body.txt not found; posting fallback message.");
body = `<!-- ta4j:post-type=release-health;run=real -->\n@${notifyUser}\n\n**Release Health Check summary unavailable**\n- Repository: ${process.env.GITHUB_REPOSITORY || `${owner}/${repo}`}\n- Run: ${runUrl}\n\nThe summary step did not produce notification-body.txt. Check earlier steps for failures.`;
}
const rawNumber = process.env.RELEASE_SCHEDULER_DISCUSSION_NUMBER;
const number = rawNumber ? Number(rawNumber) : 1414;
if (!Number.isFinite(number)) {
throw new Error(`Invalid Release Scheduler discussion number: ${rawNumber}`);
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
id
}
}
}
`;
const queryResult = await github.graphql(query, { owner, repo, number });
const discussionId = queryResult?.repository?.discussion?.id;
if (!discussionId) {
throw new Error(`Discussion ${number} not found in ${owner}/${repo}`);
}
const commentQuery = `
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
comments(first: 100, after: $cursor) {
nodes {
id
body
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
const deleteMutation = `
mutation($id: ID!) {
deleteDiscussionComment(input: { id: $id }) {
clientMutationId
}
}
`;
const healthCheckMarker = "<!-- ta4j:post-type=release-health;run=real -->";
let cursor = null;
let deletedCount = 0;
let scannedCount = 0;
do {
const commentResult = await github.graphql(commentQuery, { owner, repo, number, cursor });
const page = commentResult?.repository?.discussion?.comments;
const nodes = page?.nodes || [];
scannedCount += nodes.length;
const deletions = nodes.filter((comment) => {
if (!comment?.body) {
return false;
}
return comment.body.includes(healthCheckMarker);
});
for (const comment of deletions) {
await github.graphql(deleteMutation, { id: comment.id });
deletedCount += 1;
}
cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
core.info(`Scanned ${scannedCount} discussion comments; removed ${deletedCount} prior health check posts.`);
const mutation = `
mutation($discussionId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
comment {
url
}
}
}
`;
await github.graphql(mutation, { discussionId, body });
- name: Fail if drift detected
if: steps.summary.outputs.drift_found == 'true'
run: |
echo "::error::Release health check failed due to detected drift."
exit 1
+998
View File
@@ -0,0 +1,998 @@
# Flow:
# - analyze (checkout, diff, changelog, AI decision, compute version) -> job outputs
# - approval (environment-protected) -> reserved for future major-release re-enable
# - publish (patch/minor) -> depends on analyze
# - publish_major -> reserved for future major-release re-enable
#
#
# NOTE: major bumps are currently disabled and downgraded to minor.
# The major-release approval path is kept for future re-enable.
name: AI Semantic Release Scheduler
concurrency:
group: semantic-release-ai-${{ github.repository }}
on:
schedule:
- cron: "0 9 */14 * *" # every 14 days
workflow_dispatch:
inputs:
dryRun:
description: "Simulate only; trigger prepare-release.yml with dryRun=true"
required: false
default: true
type: boolean
permissions:
contents: write
actions: write
discussions: write
jobs:
analyze:
name: Analyze changes & ask AI
runs-on: ubuntu-latest
if: ${{ github.event_name != 'schedule' || vars.RELEASE_SCHEDULER_ENABLED == 'true' }}
outputs:
should_release: ${{ steps.parsed.outputs.should_release }}
bump: ${{ steps.parsed.outputs.bump }}
version: ${{ steps.version.outputs.version }}
reason: ${{ steps.parsed.outputs.reason }}
warning: ${{ steps.parsed.outputs.warning }}
dryRun: ${{ steps.run_meta.outputs.dryRun }}
binary_lines: ${{ steps.diff.outputs.lines }}
binary_changes: ${{ steps.diff.outputs.binary_changes }}
changelog_len: ${{ steps.changelog.outputs.length }}
gate_proceed: ${{ steps.gate.outputs.proceed }}
token_present: ${{ steps.model_token.outputs.present }}
token_gate: ${{ steps.token_gate.outputs.proceed }}
model: ${{ steps.model_catalog.outputs.model_id }}
model_max_input_tokens: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
model_max_output_tokens: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
dossier_chars: ${{ steps.build_request.outputs.dossier_chars }}
last_tag: ${{ steps.lasttag.outputs.last_reachable_tag }}
last_first_parent_tag: ${{ steps.lasttag.outputs.last_first_parent_tag }}
pom_version: ${{ steps.pom_version.outputs.version }}
pom_base: ${{ steps.pom_version.outputs.base }}
steps:
# -----------------------
# Setup and Discovery
# -----------------------
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags
- name: Resolve release tag baselines
id: lasttag
run: |
default_branch="${{ github.event.repository.default_branch }}"
target_ref="origin/${default_branch}"
git fetch origin "$default_branch" --tags
tag_state_file=$(mktemp)
scripts/resolve-release-tags.sh "$target_ref" > "$tag_state_file"
latest_tag="none"
last_reachable_tag="none"
last_first_parent_tag="none"
latest_tag_reachable="n/a"
while IFS='=' read -r key value; do
case "$key" in
latest_tag) latest_tag="$value" ;;
last_reachable_tag) last_reachable_tag="$value" ;;
last_first_parent_tag) last_first_parent_tag="$value" ;;
latest_tag_reachable) latest_tag_reachable="$value" ;;
esac
done < "$tag_state_file"
cat "$tag_state_file" >> "$GITHUB_OUTPUT"
rm -f "$tag_state_file"
echo "audit:last_tag=$last_reachable_tag default_branch=$default_branch first_parent_tag=$last_first_parent_tag prefer_no_v_tags=true"
echo "audit:latest_tag=$latest_tag latest_tag_reachable=$latest_tag_reachable"
- name: Capture event and normalize dry run flag
id: run_meta
env:
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
run: |
event="${EVENT_NAME:-}"
if [ "$event" = "workflow_dispatch" ]; then
raw="${DRY_RUN_INPUT:-true}"
normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then
dry_run=false
else
dry_run=true
fi
else
raw="${DRY_RUN_INPUT:-}"
dry_run=false
fi
echo "audit:event_name=$event"
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
- name: Read version from pom.xml
id: pom_version
run: |
set -euo pipefail
version=$(python3 -c $'import xml.etree.ElementTree as ET\nimport sys\n\ndef find_version(path):\n tree = ET.parse(path)\n root = tree.getroot()\n def find_first(elem):\n if elem is None:\n return None\n for child in elem:\n if child.tag.endswith(\"version\"):\n text = (child.text or \"\").strip()\n if text:\n return text\n return None\n return find_first(root) or find_first(root.find(\"./{*}parent\"))\n\nver = find_version(\"pom.xml\")\nif not ver:\n sys.exit(1)\nprint(ver)\n') || { echo \"Could not read <version> from pom.xml\" >&2; exit 1; }
base="$version"
is_snapshot=false
if [[ "$version" == *-SNAPSHOT ]]; then
base="${version%-SNAPSHOT}"
is_snapshot=true
fi
echo "audit:pom_version=$version base=$base snapshot=$is_snapshot"
echo "version=$version" >> $GITHUB_OUTPUT
echo "base=$base" >> $GITHUB_OUTPUT
echo "snapshot=$is_snapshot" >> $GITHUB_OUTPUT
# -----------------------
# Change Detection
# -----------------------
- name: Collect binary-impacting changes since last release
id: diff
env:
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
run: |
if [ "$LAST_TAG" = "none" ]; then
changed_files=$(git ls-tree -r --name-only HEAD)
else
changed_files=$(git diff --name-only "$LAST_TAG"..HEAD)
fi
binary_files=$(printf '%s\n' "$changed_files" | grep -E '(^|/)pom\.xml$|(^|/)src/main/' || true)
binary_files=$(printf '%s\n' "$binary_files" | sort -u)
# sanitize: mask URLs, common GH tokens, and long alnum tokens (avoid redacting long Java identifiers)
sanitized=$(printf '%s\n' "$binary_files" | perl -pe 's#https?://\S+#[REDACTED_URL]#g; s/(gh[oprsu]_[A-Za-z0-9]{20,})/[REDACTED_TOKEN]/g; s/((?=[A-Za-z0-9]{32,})(?=.*[0-9])[A-Za-z0-9]+)/[REDACTED_SECRET]/g')
if [ -z "$sanitized" ]; then
count=0
else
count=$(printf '%s\n' "$sanitized" | wc -l | tr -d ' ')
fi
summary="(none)"
if [ "$count" -gt 0 ]; then
summary=$(printf '%s\n' "$sanitized" | awk -F/ '
NF>=3 && $2 == "src" && $3 == "main" {key=$1"/"$2"/"$3; counts[key]++; next}
NF>=2 {key=$1"/"$2; counts[key]++; next}
{counts[$1]++}
END {for (k in counts) printf "%s: %d files\n", k, counts[k]}
' | sort)
fi
sample=""
if [ "$count" -gt 0 ] && [ "$count" -le 40 ]; then
sample="$sanitized"
fi
binary_prompt="$summary"
if [ -n "$sample" ]; then
binary_prompt="${binary_prompt}\nSample paths:\n${sample}"
fi
binary_prompt=$(printf '%s' "$binary_prompt" | cut -c1-1200)
binary_prompt_b64=$(printf '%s' "$binary_prompt" | base64 -w0)
binary_prompt_len=$(printf '%s' "$binary_prompt" | wc -c | tr -d ' ')
# truncate to 2000 chars for discussion output
sanitized_truncated=$(printf '%s' "$sanitized" | cut -c1-2000)
echo "audit:binary_change_lines=$count sanitized=true truncated=2000 summary_prompt_chars=$binary_prompt_len"
diff_b64=$(printf '%s' "$sanitized_truncated" | base64 -w0)
echo "binary_prompt_b64=$binary_prompt_b64" >> $GITHUB_OUTPUT
echo "binary_prompt_len=$binary_prompt_len" >> $GITHUB_OUTPUT
echo "diff_b64=$diff_b64" >> $GITHUB_OUTPUT
echo "lines=$count" >> $GITHUB_OUTPUT
delim="BIN_CHANGES_$(date +%s%N)"
{
echo "binary_changes<<$delim"
printf '%s\n' "$sanitized_truncated"
echo "$delim"
} >> "$GITHUB_OUTPUT"
- name: Check if CHANGELOG exists
id: changelog_exists
run: |
if [ ! -f CHANGELOG.md ]; then
echo "present=false" >> $GITHUB_OUTPUT
echo "audit:changelog_present=false"
else
echo "present=true" >> $GITHUB_OUTPUT
echo "audit:changelog_present=true"
fi
- name: Extract Unreleased section from CHANGELOG
id: changelog
run: |
if [ ! -f CHANGELOG.md ]; then
echo "audit:changelog_present=false"
echo "changelog_b64=" >> $GITHUB_OUTPUT
echo "length=0" >> $GITHUB_OUTPUT
exit 0
fi
unreleased=$(awk '
BEGIN{inSection=0}
/^## *\[?Unreleased\]?/ {inSection=1; next}
/^## / && inSection {exit}
inSection {print}
' CHANGELOG.md)
filtered=$(printf '%s\n' "$unreleased" | grep -E '^[[:space:]]*(#{2,4}|[-*])' || true)
if [ -n "$filtered" ]; then
unreleased="$filtered"
fi
# truncate to keep payload bounded and token friendly
unreleased=$(printf '%s' "$unreleased" | cut -c1-2000)
changelog_b64=$(printf '%s' "$unreleased" | base64 -w0)
changelog_len=$(printf '%s' "$unreleased" | wc -c | tr -d ' ')
echo "audit:changelog_present=true"
echo "changelog_b64=$changelog_b64" >> $GITHUB_OUTPUT
echo "length=$changelog_len" >> $GITHUB_OUTPUT
# -----------------------
# Gate Checks
# -----------------------
- name: Check if changes detected
id: gate
run: |
binary_lines=${{ steps.diff.outputs.lines }}
changelog_len=${{ steps.changelog.outputs.length }}
binary_lines=${binary_lines:-0}
changelog_len=${changelog_len:-0}
if [ "$binary_lines" -eq 0 ]; then
echo "proceed=false" >> $GITHUB_OUTPUT
echo "audit:short_circuit=true reason=\"no binary-impacting changes\""
else
echo "proceed=true" >> $GITHUB_OUTPUT
echo "audit:short_circuit=false"
fi
- name: No release - no binary-impacting changes
if: steps.gate.outputs.proceed != 'true'
run: echo "No release recommended (no binary-impacting changes)."
- name: Check for GH_MODELS_TOKEN
id: model_token
if: steps.gate.outputs.proceed == 'true'
env:
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
run: |
if [ -z "${GH_MODELS_TOKEN:-}" ]; then
echo "present=false" >> $GITHUB_OUTPUT
echo "audit:models_token_present=false"
exit 0
fi
echo "::add-mask::$GH_MODELS_TOKEN"
echo "GH_MODELS_TOKEN=$GH_MODELS_TOKEN" >> $GITHUB_ENV
echo "present=true" >> $GITHUB_OUTPUT
echo "audit:models_token_present=true"
- name: Check token gate
id: token_gate
if: steps.gate.outputs.proceed == 'true'
run: |
present="${{ steps.model_token.outputs.present }}"
present=${present:-false}
if [ "$present" = "true" ]; then
echo "proceed=true" >> $GITHUB_OUTPUT
echo "audit:short_circuit_token=false"
else
echo "proceed=false" >> $GITHUB_OUTPUT
echo "audit:short_circuit_token=true reason=\"missing GH_MODELS_TOKEN\""
fi
- name: No release - missing GH_MODELS_TOKEN
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed != 'true'
run: echo "GH_MODELS_TOKEN not provided; skipping release."
# -----------------------
# AI Analysis
# -----------------------
- name: Preflight GitHub Models catalog
id: model_catalog
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
env:
RELEASE_AI_MODEL: ${{ vars.RELEASE_AI_MODEL }}
run: |
set -euo pipefail
model="${RELEASE_AI_MODEL:-openai/gpt-4.1}"
echo "::group::GitHub Models catalog preflight"
python3 scripts/release/release_helpers.py catalog-preflight \
--model "$model" \
--output release-ai-model.json
echo "::endgroup::"
- name: Build release dossier
id: dossier
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
run: |
set -euo pipefail
echo "::group::Build release dossier"
python3 scripts/release/release_helpers.py build-dossier \
--last-tag "${{ steps.lasttag.outputs.last_reachable_tag }}" \
--current-version "${{ steps.pom_version.outputs.version }}" \
--pom-base "${{ steps.pom_version.outputs.base }}" \
--max-diff-chars 600000 \
--output release-dossier.md \
--audit-output release-audit.json
echo "::endgroup::"
- name: Build and validate AI request JSON
id: build_request
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
run: |
set -euo pipefail
echo "::group::Build AI request"
python3 scripts/release/release_helpers.py build-ai-request \
--model "${{ steps.model_catalog.outputs.model_id }}" \
--dossier release-dossier.md \
--semver-rules .github/workflows/semver-rules-override.txt \
--output request.json
if ! jq -e '.model and .messages and (.messages | length > 0)' request.json > /dev/null; then
echo "audit:request_json_valid=false" >&2
exit 1
fi
echo "audit:request_json_valid=true"
echo "::endgroup::"
- name: Call AI API with retry
id: ai_call
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
env:
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
run: |
set -uo pipefail
echo "::group::Call GitHub Models inference"
echo "audit:request_json_size_bytes=$(wc -c < request.json | tr -d ' ')"
echo "audit:dossier_chars=${{ steps.build_request.outputs.dossier_chars }}"
echo "audit:model=${{ steps.model_catalog.outputs.model_id }}"
echo "audit:model_max_input_tokens=${{ steps.model_catalog.outputs.model_max_input_tokens }}"
echo "audit:model_max_output_tokens=${{ steps.model_catalog.outputs.model_max_output_tokens }}"
echo "audit:external_call target=models.github.ai payload=request.json sanitized=true"
attempts=3
backoff=2
response_status=0
: > response.json
for attempt in $(seq 1 "$attempts"); do
if response_status=$(curl -s -o response.json -w '%{http_code}' \
-X POST --max-time 30 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GH_MODELS_TOKEN" \
https://models.github.ai/inference/chat/completions \
--data-binary @request.json); then
:
else
response_status="000"
fi
if [ "$response_status" -eq 200 ]; then
break
fi
echo "audit:ai_request_retry attempt=$attempt status=$response_status backoff=${backoff}s" >&2
if [ "$attempt" -lt "$attempts" ]; then
sleep "$backoff"
backoff=$((backoff * 2))
fi
done
echo "response_status=$response_status" >> $GITHUB_OUTPUT
echo "::endgroup::"
- name: Handle AI API failure
id: ai_failure
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status != '200'
env:
RESPONSE_STATUS: ${{ steps.ai_call.outputs.response_status }}
ATTEMPTS: 3
run: |
err_msg=$(jq -r '.error.message // empty' response.json 2>/dev/null || true)
err_msg=${err_msg:-"AI call failed (http ${RESPONSE_STATUS})"}
echo "audit:ai_request_failed attempts=$ATTEMPTS status=$RESPONSE_STATUS" >&2
echo "response (truncated to 500 chars):"
head -c 500 response.json || true
fallback=$(jq -n --arg msg "$err_msg" --arg status "$RESPONSE_STATUS" \
'{should_release:false,bump:"patch",warning:$msg,reason:$msg}')
printf '%s\n' "$fallback" > ai-content.txt
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
printf '%s\n' "$fallback"
echo "$delim"
} >> $GITHUB_OUTPUT
- name: Extract AI response content
id: ai
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status == '200'
run: |
resp_size=$(wc -c < response.json | tr -d ' ')
echo "audit:ai_response_bytes=$resp_size saved_to=response.json"
if ! jq -e '.choices and .choices[0].message and (.choices[0].message.content != null)' response.json > /dev/null; then
echo "audit:ai_response_missing_content=true" >&2
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
echo ""
echo "$delim"
} >> $GITHUB_OUTPUT
exit 0
fi
content=$(jq -r '.choices[0].message.content // ""' response.json)
printf '%s\n' "$content" > ai-content.txt
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
printf '%s\n' "$content"
echo "$delim"
} >> $GITHUB_OUTPUT
- name: Parse AI JSON
id: parsed
if: always()
env:
GATE_PROCEED: ${{ steps.gate.outputs.proceed }}
MODEL_TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
run: |
gate_proceed="${GATE_PROCEED:-false}"
if [ "$gate_proceed" != "true" ]; then
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"No binary changes","reason":"No binary-impacting changes detected"}' > ai-content.txt
else
model_token_present="${MODEL_TOKEN_PRESENT:-false}"
if [ "$model_token_present" != "true" ]; then
echo "AI call skipped because GH_MODELS_TOKEN is missing." >&2
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"GH_MODELS_TOKEN missing","reason":"GH_MODELS_TOKEN secret missing; cannot call AI"}' > ai-content.txt
elif [ ! -s ai-content.txt ]; then
echo '{"should_release":false,"bump":"patch","confidence":0.0,"warning":"Missing AI response","reason":"AI response content missing"}' > ai-content.txt
fi
fi
echo "::group::Parse AI release decision"
python3 scripts/release/release_helpers.py parse-decision \
--raw-file ai-content.txt \
--output release-decision.json \
--github-output "$GITHUB_OUTPUT"
echo "::endgroup::"
- name: Stop if AI says no release
if: steps.parsed.outputs.should_release == 'false'
env:
REASON: ${{ steps.parsed.outputs.reason }}
run: |
echo "No release recommended."
if [ -n "${REASON:-}" ]; then
echo "Reason: ${REASON}"
fi
# -----------------------
# Version Computation
# -----------------------
- name: Compute new version
id: version
if: steps.parsed.outputs.should_release == 'true'
run: |
set -euo pipefail
last="${{ steps.lasttag.outputs.last_reachable_tag }}"
current="${{ steps.pom_version.outputs.version }}"
base="${{ steps.pom_version.outputs.base }}"
bump="${{ steps.parsed.outputs.bump }}"
if [ -z "$current" ] || [ -z "$base" ]; then
echo "Failed to read version from pom.xml" >&2
exit 1
fi
# Normalize base version (allow major.minor or major.minor.patch)
base_norm="$base"
if [[ "$base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
base_norm="${base}.0"
elif [[ "$base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
: # already normalized
else
echo "POM version '$current' (base '$base') is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
exit 1
fi
bump_base="$base"
# Prevent regressions relative to the last reachable default-branch release tag
if [ "$last" != "none" ] && echo "$last" | grep -Eq '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-+].*)?$'; then
last_norm=$(printf '%s\n' "$last" | sed -E 's/^v?([0-9]+)\.([0-9]+)(\.[0-9]+)?([-+].*)?$/\1.\2\3/')
last_norm=${last_norm:-}
last_norm=${last_norm%.}
if echo "$last_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
last_norm="${last_norm}.0"
fi
if [ -n "$last_norm" ]; then
highest=$(printf '%s\n' "$last_norm" "$base_norm" | sort -V | tail -n1)
if [ "$highest" != "$base_norm" ]; then
echo "POM base version '$base' is behind the last reachable default-branch tag '$last_norm'; refusing to compute a lower release." >&2
exit 1
fi
bump_base="$last_norm"
fi
fi
# Use the selected bump base (prefer the last reachable tag when present) for incrementing
if [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch=0
elif [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
else
echo "Bump base '$bump_base' is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
exit 1
fi
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
patch|"") patch=$((patch+1)) ;;
*) echo "Invalid bump value: $bump" >&2; exit 1 ;;
esac
new="${major}.${minor}.${patch}"
new_norm="$new"
if echo "$new_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
new_norm="${new_norm}.0"
fi
# Ensure we never release a version lower than the POM base
if [ "$(printf '%s\n' "$new_norm" "$base_norm" | sort -V | tail -n1)" != "$new_norm" ]; then
echo "Computed version '$new' would be lower than pom.xml base '$base'; raising to pom base." >&2
new="$base"
new_norm="$base_norm"
fi
if git rev-parse "refs/tags/$new" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new" >/dev/null 2>&1 || { [ "$new" != "$new_norm" ] && (git rev-parse "refs/tags/$new_norm" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new_norm" >/dev/null 2>&1); }; then
echo "Computed tag $new (or v$new) already exists. Aborting." >&2
exit 1
fi
echo "version=$new" >> $GITHUB_OUTPUT
# -----------------------
# Summary
# -----------------------
- name: Debug decision summary
if: always()
env:
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
DRY_RUN: ${{ steps.run_meta.outputs.dryRun }}
PROCEED: ${{ steps.gate.outputs.proceed }}
TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
TOKEN_GATE: ${{ steps.token_gate.outputs.proceed }}
MODEL: ${{ steps.model_catalog.outputs.model_id }}
MODEL_MAX_INPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
MODEL_MAX_OUTPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
DOSSIER_CHARS: ${{ steps.build_request.outputs.dossier_chars }}
REQUEST_JSON_SIZE_BYTES: ${{ steps.build_request.outputs.request_json_size_bytes }}
SHOULD_RELEASE: ${{ steps.parsed.outputs.should_release }}
BUMP: ${{ steps.parsed.outputs.bump }}
CONFIDENCE: ${{ steps.parsed.outputs.confidence }}
REASON: ${{ steps.parsed.outputs.reason }}
VERSION: ${{ steps.version.outputs.version }}
POM_VERSION: ${{ steps.pom_version.outputs.version }}
POM_BASE: ${{ steps.pom_version.outputs.base }}
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
run: |
if [ "${DRY_RUN:-false}" = "true" ]; then
mutation_plan="would dispatch prepare-release.yml with dryRun=true; no discussion post is created"
rerun_guidance="rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
else
mutation_plan="dispatches prepare-release.yml with dryRun=false when the scheduler decides to release"
rerun_guidance="not needed for scheduled production runs"
fi
cat > release-scheduler-mutation-plan.txt <<EOF
dryRun=${DRY_RUN:-}
computedVersion=${VERSION:-}
bump=${BUMP:-}
mutationPlan=${mutation_plan}
rerunGuidance=${rerun_guidance}
EOF
echo "decision:event_name=${EVENT_NAME:-}"
echo "decision:dry_run_input='${DRY_RUN_INPUT:-}' normalized=${DRY_RUN:-}"
echo "decision:gate_proceed=${PROCEED:-}"
echo "decision:token_present=${TOKEN_PRESENT:-}"
echo "decision:token_gate_proceed=${TOKEN_GATE:-}"
echo "decision:model=${MODEL:-}"
echo "decision:model_max_input_tokens=${MODEL_MAX_INPUT_TOKENS:-}"
echo "decision:model_max_output_tokens=${MODEL_MAX_OUTPUT_TOKENS:-}"
echo "decision:dossier_chars=${DOSSIER_CHARS:-}"
echo "decision:request_json_size_bytes=${REQUEST_JSON_SIZE_BYTES:-}"
echo "decision:should_release=${SHOULD_RELEASE:-}"
echo "decision:bump=${BUMP:-}"
echo "decision:confidence=${CONFIDENCE:-}"
echo "decision:reason=${REASON:-}"
echo "decision:computed_version=${VERSION:-}"
echo "decision:pom_version=${POM_VERSION:-} pom_base=${POM_BASE:-}"
echo "decision:last_reachable_default_branch_tag=${LAST_TAG:-}"
echo "decision:last_first_parent_tag=${{ steps.lasttag.outputs.last_first_parent_tag }}"
{
echo "## Release Scheduler Decision"
echo ""
echo "- model: ${MODEL:-unknown}"
echo "- model max input tokens: ${MODEL_MAX_INPUT_TOKENS:-unknown}"
echo "- model max output tokens: ${MODEL_MAX_OUTPUT_TOKENS:-unknown}"
echo "- dossier chars: ${DOSSIER_CHARS:-0}"
echo "- request bytes: ${REQUEST_JSON_SIZE_BYTES:-0}"
echo "- should release: ${SHOULD_RELEASE:-unknown}"
echo "- bump: ${BUMP:-unknown}"
echo "- confidence: ${CONFIDENCE:-unknown}"
echo "- computed version: ${VERSION:-none}"
echo "- last reachable tag: ${LAST_TAG:-unknown}"
echo "- reason: ${REASON:-none}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release scheduler audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-scheduler-audit-${{ github.run_id }}
path: |
release-ai-model.json
release-dossier.md
release-audit.json
request.json
response.json
ai-content.txt
release-decision.json
release-scheduler-mutation-plan.txt
if-no-files-found: ignore
retention-days: 14
# Reserved major-release approval path.
# Major bumps are currently downgraded to minor in analyze/parsing and this job should not run.
approval:
name: Major release approval (human)
needs: analyze
runs-on: ubuntu-latest
if: ${{ needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' }}
environment:
name: major-release
steps:
- name: Wait for approval (environment protects this job)
run: |
echo "This job is blocked until an authorized reviewer approves or rejects in the GitHub UI."
echo "Computed version to approve: ${{ needs.analyze.outputs.version }}"
echo "Approvers must use the Environments UI or the Actions run page to approve."
# Publish job: runs automatically for non-major bumps, and after approval for major bumps.
publish:
name: Trigger publish workflow (patch/minor)
needs: analyze
runs-on: ubuntu-latest
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump != 'major'
steps:
- name: Trigger publish workflow
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
if (shouldRelease !== "true") {
console.log("audit:skip_dispatch reason=should_release_false");
return;
}
const version = "${{ needs.analyze.outputs.version }}";
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
const ref = "${{ github.event.repository.default_branch }}";
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "prepare-release.yml",
ref,
inputs: {
releaseVersion: version,
dryRun
}
})
publish_major:
name: Trigger publish workflow (major after approval)
needs: [analyze, approval]
runs-on: ubuntu-latest
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' && needs.approval.result == 'success'
steps:
- name: Trigger publish workflow
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
if (shouldRelease !== "true") {
console.log("audit:skip_dispatch reason=should_release_false");
return;
}
const version = "${{ needs.analyze.outputs.version }}";
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
const ref = "${{ github.event.repository.default_branch }}";
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "prepare-release.yml",
ref,
inputs: {
releaseVersion: version,
dryRun
}
})
# Dry-run convenience job to show the recommendation alongside the dry-run prepare dispatch.
dry_run_output:
name: Dry-run output
runs-on: ubuntu-latest
needs: analyze
if: ${{ needs.analyze.outputs.dryRun == 'true' && needs.analyze.outputs.should_release == 'true' }}
steps:
- name: Show dry-run results
run: |
echo "======================="
echo " DRY RUN MODE "
echo "======================="
echo "AI recommends release:"
echo " bump: ${{ needs.analyze.outputs.bump }}"
echo " version: ${{ needs.analyze.outputs.version }}"
echo ""
echo "(Dry run enabled - propagating dryRun=true to prepare-release.yml)"
{
echo "## Release Scheduler Dry Run"
echo ""
echo "- recommended bump: ${{ needs.analyze.outputs.bump }}"
echo "- computed version: ${{ needs.analyze.outputs.version }}"
echo "- mutation plan: would dispatch prepare-release.yml with dryRun=true"
echo "- rerun to mutate: rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
} >> "$GITHUB_STEP_SUMMARY"
notify:
name: Notify release-scheduler outcome
runs-on: ubuntu-latest
needs: [analyze, approval, publish, publish_major, dry_run_output]
if: always()
steps:
- name: Build discussion comment
id: notify_summary
if: always()
env:
EVENT_NAME: ${{ github.event_name }}
ANALYZE_RESULT: ${{ needs.analyze.result }}
APPROVAL_RESULT: ${{ needs.approval.result }}
PUBLISH_RESULT: ${{ needs.publish.result }}
PUBLISH_MAJOR_RESULT: ${{ needs.publish_major.result }}
DRY_RUN_RESULT: ${{ needs.dry_run_output.result }}
SHOULD_RELEASE: ${{ needs.analyze.outputs.should_release }}
BUMP: ${{ needs.analyze.outputs.bump }}
VERSION: ${{ needs.analyze.outputs.version }}
REASON: ${{ needs.analyze.outputs.reason }}
WARNING: ${{ needs.analyze.outputs.warning }}
DRY_RUN: ${{ needs.analyze.outputs.dryRun }}
BINARY_LINES: ${{ needs.analyze.outputs.binary_lines }}
BINARY_CHANGES: ${{ needs.analyze.outputs.binary_changes }}
CHANGELOG_LEN: ${{ needs.analyze.outputs.changelog_len }}
GATE_PROCEED: ${{ needs.analyze.outputs.gate_proceed }}
TOKEN_PRESENT: ${{ needs.analyze.outputs.token_present }}
TOKEN_GATE: ${{ needs.analyze.outputs.token_gate }}
MODEL: ${{ needs.analyze.outputs.model }}
MODEL_MAX_INPUT_TOKENS: ${{ needs.analyze.outputs.model_max_input_tokens }}
MODEL_MAX_OUTPUT_TOKENS: ${{ needs.analyze.outputs.model_max_output_tokens }}
DOSSIER_CHARS: ${{ needs.analyze.outputs.dossier_chars }}
LAST_TAG: ${{ needs.analyze.outputs.last_tag }}
LAST_FIRST_PARENT_TAG: ${{ needs.analyze.outputs.last_first_parent_tag }}
POM_VERSION: ${{ needs.analyze.outputs.pom_version }}
POM_BASE: ${{ needs.analyze.outputs.pom_base }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
run: |
status="success"
for result in "$ANALYZE_RESULT" "$APPROVAL_RESULT" "$PUBLISH_RESULT" "$PUBLISH_MAJOR_RESULT" "$DRY_RUN_RESULT"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
status="failure"
break
fi
done
status_upper=$(printf '%s' "$status" | tr '[:lower:]' '[:upper:]')
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
gate_proceed="${GATE_PROCEED:-unknown}"
binary_lines="${BINARY_LINES:-0}"
changelog_len="${CHANGELOG_LEN:-0}"
token_present="${TOKEN_PRESENT:-unknown}"
token_gate="${TOKEN_GATE:-unknown}"
model="${MODEL:-}"
model_max_input_tokens="${MODEL_MAX_INPUT_TOKENS:-}"
model_max_output_tokens="${MODEL_MAX_OUTPUT_TOKENS:-}"
dossier_chars="${DOSSIER_CHARS:-}"
should_release="${SHOULD_RELEASE:-unknown}"
bump="${BUMP:-unknown}"
version="${VERSION:-}"
reason="${REASON:-}"
warning="${WARNING:-}"
dry_run="${DRY_RUN:-unknown}"
last_tag="${LAST_TAG:-}"
last_first_parent_tag="${LAST_FIRST_PARENT_TAG:-}"
pom_version="${POM_VERSION:-}"
pom_base="${POM_BASE:-}"
binary_changes="${BINARY_CHANGES:-}"
if [ "$dry_run" = "true" ]; then
mode_label="dry-run"
marker_run="dry-run"
else
mode_label="production"
marker_run="real"
fi
if [ "$status" = "success" ]; then
completion_text="completed successfully"
else
completion_text="completed with failures"
fi
timestamp=$(date '+%-I:%M:%S %p on %-m.%-d.%Y')
if [ -z "$binary_changes" ]; then
binary_changes="(none)"
fi
if [ -z "$version" ]; then
version="(none)"
fi
if [ -z "$reason" ]; then
reason="(none)"
fi
if [ -z "$warning" ]; then
warning="(none)"
fi
if [ -z "$last_tag" ]; then
last_tag="(none)"
fi
if [ -z "$last_first_parent_tag" ]; then
last_first_parent_tag="(none)"
fi
if [ -z "$pom_version" ]; then
pom_version="(unknown)"
fi
if [ -z "$pom_base" ]; then
pom_base="(unknown)"
fi
if [ -z "$model" ]; then
model="(none)"
fi
if [ -z "$model_max_input_tokens" ]; then
model_max_input_tokens="(unknown)"
fi
if [ -z "$model_max_output_tokens" ]; then
model_max_output_tokens="(unknown)"
fi
if [ -z "$dossier_chars" ]; then
dossier_chars="0"
fi
notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}"
cat > notification-body.txt <<EOF
<!-- ta4j:post-type=release-scheduler;run=${marker_run};lastTag=${last_tag};firstParentTag=${last_first_parent_tag};pomVersion=${pom_version};pomBase=${pom_base} -->
@${notify_user}
**Release Scheduler ${mode_label} ${completion_text} at ${timestamp}**
- Repository: ${GITHUB_REPOSITORY}
- Run: ${run_url}
- Event: ${EVENT_NAME}
- Status: ${status_upper}
Decision Summary:
- gate proceed: ${gate_proceed}
- binary change count: ${binary_lines}
- changelog length: ${changelog_len}
- models token present: ${token_present}
- token gate proceed: ${token_gate}
- model: ${model}
- model max input tokens: ${model_max_input_tokens}
- model max output tokens: ${model_max_output_tokens}
- dossier chars: ${dossier_chars}
- should release: ${should_release}
- bump: ${bump}
- computed version: ${version}
- reason: ${reason}
- warning: ${warning}
- dryRun: ${dry_run}
- last reachable tag: ${last_tag}
- last first-parent tag: ${last_first_parent_tag}
- pom version: ${pom_version}
- pom base: ${pom_base}
Binary-impacting changes (paths):
<details>
<summary>Show paths</summary>
${binary_changes}
</details>
Job Results:
- analyze: ${ANALYZE_RESULT}
- approval: ${APPROVAL_RESULT}
- publish: ${PUBLISH_RESULT}
- publish_major: ${PUBLISH_MAJOR_RESULT}
- dry_run_output: ${DRY_RUN_RESULT}
EOF
- name: Post to Release Scheduler discussion
if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'
uses: actions/github-script@v9
env:
RELEASE_SCHEDULER_DISCUSSION_NUMBER: ${{ vars.RELEASE_SCHEDULER_DISCUSSION_NUMBER }}
with:
github-token: ${{ github.token }}
script: |
const fs = require("fs");
const body = fs.readFileSync("notification-body.txt", "utf8");
const owner = context.repo.owner;
const repo = context.repo.repo;
const rawNumber = process.env.RELEASE_SCHEDULER_DISCUSSION_NUMBER;
const number = rawNumber ? Number(rawNumber) : 1414;
if (!Number.isFinite(number)) {
throw new Error(`Invalid Release Scheduler discussion number: ${rawNumber}`);
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
id
}
}
}
`;
const queryResult = await github.graphql(query, { owner, repo, number });
const discussion = queryResult?.repository?.discussion;
const discussionId = discussion?.id;
if (!discussionId) {
throw new Error(`Discussion ${number} not found in ${owner}/${repo}`);
}
const mutation = `
mutation($discussionId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
comment {
url
}
}
}
`;
await github.graphql(mutation, { discussionId, body });
+11
View File
@@ -0,0 +1,11 @@
Decide release go/no-go from unreleased binary-impacting changes.
If go, choose bump: patch|minor.
MINOR: backward-compatible, user-visible new features, and breaking-change cases.
PATCH: backward-compatible bug fixes or internal improvements.
If binary change count is 0 => should_release=false.
If binary change list empty or changelog-only => should_release=false.
If unsure between MINOR and PATCH, prefer PATCH.
Pre-1.0.0: breaking changes can be MINOR.
+247
View File
@@ -0,0 +1,247 @@
name: Publish Snapshot to Maven Central
permissions:
contents: read
on:
push:
branches:
- master
workflow_dispatch:
inputs:
sourceReleaseVersion:
description: "Optional release version that requested this snapshot publication."
required: false
type: string
sourceReleaseCommit:
description: "Optional release commit SHA that requested this snapshot publication."
required: false
type: string
dryRun:
description: "Run snapshot workflow in dry-run mode (build/test only; no deploy)."
required: false
default: true
type: boolean
concurrency:
group: snapshot-${{ github.repository }}
cancel-in-progress: true
jobs:
snapshot:
name: Publish Snapshot to Maven Central
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Normalize dry run input
id: dry_run
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
raw="${{ github.event.inputs.dryRun }}"
if [ -z "$raw" ]; then
raw=true
fi
normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then
dry_run=false
else
dry_run=true
fi
else
raw=""
dry_run=false
fi
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
- name: Read snapshot version
id: version
run: |
set -euo pipefail
snapshot_version=$(python3 - <<'PY'
import sys
import xml.etree.ElementTree as ET
def find_version(path):
tree = ET.parse(path)
root = tree.getroot()
def find_first(elem):
if elem is None:
return None
for child in elem:
if child.tag.endswith("version"):
text = (child.text or "").strip()
if text:
return text
return None
return find_first(root) or find_first(root.find("./{*}parent"))
ver = find_version("pom.xml")
if not ver:
sys.exit(1)
print(ver)
PY
) || { echo "Could not read pom.xml version" >&2; exit 1; }
echo "audit:snapshot_version=${snapshot_version}"
echo "snapshotVersion=${snapshot_version}" >> "$GITHUB_OUTPUT"
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up JDK 25 (dry-run without publishing secrets)
if: steps.dry_run.outputs.dryRun == 'true'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Set up JDK 25 with publishing secrets
if: steps.dry_run.outputs.dryRun != 'true'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
server-id: central
server-username: ${{ secrets.MAVEN_CENTRAL_TOKEN_USER }}
server-password: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }}
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Write Maven settings
if: steps.dry_run.outputs.dryRun != 'true'
run: |
echo "::group::Write Maven settings"
mkdir -p ~/.m2
printf '%s\n' \
'<settings>' \
' <servers>' \
' <server>' \
' <id>central</id>' \
" <username>${{ secrets.MAVEN_CENTRAL_TOKEN_USER }}</username>" \
" <password>${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }}</password>" \
' </server>' \
' </servers>' \
'</settings>' > ~/.m2/settings.xml
echo "settings.xml created"
echo "::endgroup::"
- name: Write Maven security settings
if: steps.dry_run.outputs.dryRun != 'true'
shell: bash
env:
MAVEN_SECURITY_MASTER: ${{ secrets.MAVEN_MASTER_PASSPHRASE }}
run: |
set -euo pipefail
if [[ -z "${MAVEN_SECURITY_MASTER:-}" ]]; then
echo "MAVEN_MASTER_PASSPHRASE not set; skipping settings-security.xml generation"
exit 0
fi
mkdir -p ~/.m2
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<settingsSecurity>' \
" <master>${MAVEN_SECURITY_MASTER}</master>" \
'</settingsSecurity>' > ~/.m2/settings-security.xml
chmod 600 ~/.m2/settings-security.xml
echo "settings-security.xml created"
- name: Configure GPG for signing
if: steps.dry_run.outputs.dryRun != 'true'
shell: bash
run: |
set -euo pipefail
mkdir -p ~/.gnupg
chmod 700 ~/.gnupg
echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf
chmod 600 ~/.gnupg/gpg.conf
touch ~/.gnupg/gpg-agent.conf
echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
chmod 600 ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent 2>/dev/null || true
gpgconf --launch gpg-agent 2>/dev/null || true
echo "gpg.conf and gpg-agent.conf created"
- name: Dry-run mode notice
if: steps.dry_run.outputs.dryRun == 'true'
run: |
echo "DRY RUN ENABLED: running snapshot build/test only; Maven Central deployment is skipped."
- name: Build and Test
run: |
set -euo pipefail
echo "::group::Build and test snapshot candidate"
mvn -B clean test
echo "::endgroup::"
- name: Deploy Snapshot
if: steps.dry_run.outputs.dryRun != 'true'
env:
MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
set -euo pipefail
echo "::group::Deploy snapshot to Maven Central"
mvn -B deploy -Psign-snapshots 2>&1 | tee snapshot-deploy.log
echo "::endgroup::"
- name: Snapshot publication summary
if: always()
env:
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
SNAPSHOT_VERSION: ${{ steps.version.outputs.snapshotVersion }}
SOURCE_RELEASE_VERSION: ${{ github.event.inputs.sourceReleaseVersion }}
SOURCE_RELEASE_COMMIT: ${{ github.event.inputs.sourceReleaseCommit }}
run: |
if [ "${DRY_RUN:-false}" = "true" ]; then
mutation_plan="would configure publishing secrets and deploy the snapshot to Maven Central"
rerun_guidance="rerun snapshot.yml with dryRun=false, or let the master push/publish handoff path run"
else
mutation_plan="completed snapshot deployment"
rerun_guidance="not needed for this non-dry-run snapshot run"
fi
cat > snapshot-audit.json <<EOF
{
"event": "${GITHUB_EVENT_NAME}",
"dryRun": "${DRY_RUN:-}",
"snapshotVersion": "${SNAPSHOT_VERSION:-}",
"sourceReleaseVersion": "${SOURCE_RELEASE_VERSION:-}",
"sourceReleaseCommit": "${SOURCE_RELEASE_COMMIT:-}",
"mutationPlan": "${mutation_plan}",
"rerunGuidance": "${rerun_guidance}",
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
}
EOF
{
echo "## Snapshot Publication"
echo ""
echo "- event: ${GITHUB_EVENT_NAME}"
echo "- dry run: ${DRY_RUN:-unknown}"
echo "- snapshot version: ${SNAPSHOT_VERSION:-unknown}"
echo "- source release version: ${SOURCE_RELEASE_VERSION:-none}"
echo "- source release commit: ${SOURCE_RELEASE_COMMIT:-none}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload snapshot audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: snapshot-audit-${{ github.run_id }}
path: |
snapshot-audit.json
snapshot-deploy.log
if-no-files-found: ignore
retention-days: 14
+107
View File
@@ -0,0 +1,107 @@
name: Run Analysis Demo Tagged Tests
permissions:
contents: read
on:
schedule:
- cron: "7 7 * * *"
- cron: "17 7 * * 1"
- cron: "27 7 1 * *"
workflow_dispatch:
inputs:
instrument:
description: "Provider-qualified instrument, for example coinbase:BTC-USD"
required: false
default: "coinbase:BTC-USD"
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test-analysis-demo:
name: Build and Test (analysis-demo tag)
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_ENABLED == 'true' &&
(
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'daily' && github.event.schedule == '7 7 * * *') ||
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '17 7 * * 1') ||
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '27 7 1 * *')
)
)
}}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Resolve analysis demo instrument
env:
DEFAULT_ANALYSIS_DEMO_INSTRUMENT: coinbase:BTC-USD
DISPATCH_INSTRUMENT: ${{ github.event.inputs.instrument }}
SCHEDULED_INSTRUMENT: ${{ vars.TA4J_ANALYSIS_DEMO_INSTRUMENT }}
run: |
set -euo pipefail
instrument="${DEFAULT_ANALYSIS_DEMO_INSTRUMENT}"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
if [ -n "${DISPATCH_INSTRUMENT:-}" ]; then
instrument="${DISPATCH_INSTRUMENT}"
fi
elif [ -n "${SCHEDULED_INSTRUMENT:-}" ]; then
instrument="${SCHEDULED_INSTRUMENT}"
fi
provider="${instrument%%:*}"
product="${instrument#*:}"
if [ "${instrument}" = "${product}" ] || [ -z "${provider}" ] || [ -z "${product}" ]; then
echo "::error::analysis-demo instrument must use provider-qualified format, for example coinbase:BTC-USD"
exit 1
fi
if [ "${provider,,}" != "coinbase" ]; then
echo "::error::Unsupported analysis-demo provider '${provider}'. Supported providers: coinbase"
exit 1
fi
if [[ ! "${product}" =~ ^[A-Za-z0-9]+([/-][A-Za-z0-9]+)+$ ]]; then
echo "::error::Coinbase analysis-demo product id must use Coinbase product format, for example BTC-USD"
exit 1
fi
printf 'TA4J_ANALYSIS_DEMO_INSTRUMENT=%s\n' "${instrument}" >> "${GITHUB_ENV}"
echo "analysis-demo:instrument=${instrument}"
- name: Build and run analysis-demo tagged tests with Maven
run: >
xvfb-run mvn -B test
-Dgroups=analysis-demo
-Dta4j.excludedTestTags=elliott-macro-cycle-replay
-Dta4j.analysisDemoInstrument="${TA4J_ANALYSIS_DEMO_INSTRUMENT}"
-Dta4j.analysisDemoOutputDir=target/analysis-demos/elliott-wave
- name: Upload analysis demo artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: analysis-demo-elliott-wave
path: target/analysis-demos/**
if-no-files-found: warn
+54
View File
@@ -0,0 +1,54 @@
name: Run Benchmark Tagged Tests
permissions:
contents: read
on:
schedule:
- cron: "57 4 * * *"
- cron: "57 5 * * 1"
- cron: "57 6 1 * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test-benchmark:
name: Build and Test (benchmark tag)
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_ENABLED == 'true' &&
(
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'daily' && github.event.schedule == '57 4 * * *') ||
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '57 5 * * 1') ||
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '57 6 1 * *')
)
)
}}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build and run benchmark tagged tests with Maven
run: xvfb-run mvn -B test -Dgroups=benchmark -Dta4j.excludedTestTags= -Dta4j.runBenchmarks=true
@@ -0,0 +1,42 @@
name: Run Elliott Macro Cycle Replay Tagged Tests
permissions:
contents: read
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test-elliott-macro-cycle-replay:
name: Build and Test (elliott-macro-cycle-replay tag)
runs-on: [self-hosted, ta4j-macro-cycle-replay]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build and run Elliott macro-cycle replay tagged tests with Maven
run: >
xvfb-run mvn -B test
-Dgroups=elliott-macro-cycle-replay
-Dta4j.excludedTestTags=
-Dtest=ElliottWaveMacroCycleDetectorTest
+54
View File
@@ -0,0 +1,54 @@
name: Run Integration Tagged Tests
permissions:
contents: read
on:
schedule:
- cron: "17 4 * * *"
- cron: "17 5 * * 1"
- cron: "17 6 1 * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test-integration:
name: Build and Test (integration tag)
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_ENABLED == 'true' &&
(
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'daily' && github.event.schedule == '17 4 * * *') ||
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '17 5 * * 1') ||
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '17 6 1 * *')
)
)
}}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build and run integration tagged tests with Maven
run: xvfb-run mvn -B test -Dgroups=integration -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay
+69
View File
@@ -0,0 +1,69 @@
name: Run Verify
permissions:
contents: read
on:
pull_request:
branches:
- master
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Build and Verify
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build and run verify with Maven
run: xvfb-run mvn -B verify
test-all-tags:
name: Build and Verify (All Non-Demo Tags)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Build and run verify with demo tags excluded
run: xvfb-run mvn -B verify -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay
+39
View File
@@ -0,0 +1,39 @@
name: Validate Source Code
permissions:
contents: read
on:
pull_request:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Validate Source Code Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Maven packages
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: maven
- name: Validate source code formatting
run: xvfb-run mvn -B formatter:validate
+55
View File
@@ -0,0 +1,55 @@
.classpath
.project
.settings/
.idea/
.vscode/
.svn/
.agents/
.codex/
nb-configuration.xml
nbactions.xml
*.code-workspace
*.iml
.DS_Store
log/
bin/
target/
out/
# Compiled class file
*.class
# Log file
*.log
# Images
*.jpg
# Exception: allow documentation images in ta4j-examples
!ta4j-examples/docs/img/*.jpg
!ta4j-examples/docs/img/*.png
# BlueJ files
*.ctxt
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# temp files #
temp/
ta4j-examples/indicators.csv
# local TODO notes (detailed architecture docs live in ta4j-wiki/architecture/)
todo/
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
/html/
+62
View File
@@ -0,0 +1,62 @@
# AGENTS instructions for ta4j (root)
This file contains only repository-wide requirements.
If your framework already injected this file into context, do not spend extra tool calls reopening it repeatedly.
## 1) Completion gate (MUST)
Run the full verification script once when you have a candidate final patch:
- `scripts/run-full-build-quiet.sh`
You may skip this only when all changed files are exclusively within `.github/workflows/`, `CHANGELOG.md`, or documentation-only files (for example `*.md`, `docs/`).
Required outcome:
- Build is GREEN (`Failures: 0`, `Errors: 0`)
- Final report includes aggregated `Tests run / Failures / Errors / Skipped`
- Final report includes the log path emitted by the script (under `.agents/logs/`)
Do not rerun the full build after every edit. Use targeted tests while iterating, then run the full gate on the candidate final patch.
## 2) Fast feedback loop (SHOULD)
During implementation and debugging, prefer targeted test commands for speed (for example `mvn -pl ta4j-core test -Dtest=...`).
## 3) Test failure policy (MUST)
- Never ignore build or test failures.
- Never skip tests without explicit user approval.
## 4) Scoped AGENTS lookup (MUST)
Before editing a feature area, discover and follow all applicable scoped `AGENTS.md` files.
- Use `bash scripts/agents_for_target.sh <file-or-class>` to list path-scoped `AGENTS.md` guides for that target in precedence order from the current repo/workspace root.
- Deeper/closer `AGENTS.md` files override broader ones.
- Apply only instructions relevant to the path you are changing.
- This helper is only for path-scoped `AGENTS.md` discovery. Agents must still apply normal system, developer, user, and workflow-specific instructions independently, including any personal PR workflow guidance.
## 5) Process/worktree guidance
Worktree lifecycle and PRD/checklist process conventions live in `scripts/AGENTS.md`.
## 6) Reuse-first policy (MUST)
- Before adding a new type or API, search for existing equivalents and reuse them when possible.
- Do not introduce a new enum when an existing project enum already models the behavior.
- Prefer extending/adapting existing classes over creating parallel abstractions.
- If a new type is still required, document in the PRD/checklist or PR notes why existing types were insufficient.
- Reuse audit checkpoint: before introducing a new type, run a targeted code search for equivalent behavior and capture the reuse decision in 1-2 lines in PRD/checklist or PR notes.
## 7) Consolidation and API exposure (MUST)
- Consolidation-first helper rule: inline new logic into the owning type (private static methods or package-private nested helpers) before creating a new utility class.
- Only extract to a dedicated helper/utility class after at least two concrete call sites require shared behavior, or when testability/complexity clearly demands extraction.
- Readability-first helper rule: do not extract private helpers that are only 1-3 lines and used once unless the extracted name carries important domain meaning or materially simplifies a long method.
- When a tiny helper forces readers to jump around for one or two lines of logic, inline it back into the main flow.
- When stronger invariants already hold at the call site, prefer direct code over generic fallback helpers that obscure those invariants.
- New classes and methods should be package-private by default; treat public API additions as exceptional and require a short rationale in PRD/checklist or PR notes.
- Redundancy cleanup requirement: when new code overlaps existing behavior, remove or fold the redundant abstraction in the same change unless there is a documented blocker.
## 8) Local typing style (MUST)
- Prefer explicit local variable types.
- Use `var` only when the type is immediately and unambiguously obvious from the right-hand side.
- Do not use `var` for method-return values unless the type is trivial and fully clear from constructor/factory literal context.
+22
View File
@@ -0,0 +1,22 @@
Maintainer / Lead developer
TheCookieLab
Contributors
mdeverdelhan (Marc de Verdelhan)
Bastien Bonard
dkcm (Daniel Kuan)
dan-lind (Daniel Lindberg)
lequer (Michel Le Quer)
ElMoe (Bastian Engelmann)
gcauchis (Gabriel Cauchis)
bukefalos
TheCookieLab
team172011 (Simon-Justus Wimmer)
nimo23
edlins
milczarekIT (Bartosz Milczarek)
kennethjor (Kenneth Jørgensen)
brhurley (Brian Hurley)
francescopeloi (Francesco Peloi)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
ta4j.org
+19
View File
@@ -0,0 +1,19 @@
# Code of Conduct
We follow the [Contributor Covenant](https://www.contributor-covenant.org) (v2.1). [Full text](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
## Standards
- **Expected:** Respect, constructive feedback, and focus on what benefits the community.
- **Not acceptable:** Harassment, trolling, insults, sexualized language or attention, doxing, or other conduct inappropriate in a professional setting.
## Enforcement
Report unacceptable behavior to the project maintainers. Complaints are reviewed promptly and in confidence. Consequences may range from a private warning to a temporary or permanent ban from community spaces.
## Attribution
This code of conduct is adapted from the [Contributor Covenant][v2.1]. Community impact guidelines were inspired by [Mozilla's enforcement ladder][Mozilla].
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla]: https://github.com/mozilla/diversity
+2641
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# ta4j Pull Request Overrides
These repo-local instructions override the user-level PR defaults for ta4j. Apply only the ID-targeted directives below; unscoped prose is additive.
Disable PDL.TASK.PR_MERGE:
Terminal condition is a ready, non-draft PR with green local verification, acceptable CI, a clean final PR sweep, no unresolved conversations, and explicit human merge handoff. Do not merge into `master` automatically.
Override PDL.TASK.LINEAR_CLOSEOUT:
Unless a human merge happens in the same run, move the source issue/document to review or ready-for-merge state, not done. Mark done only after the PR is actually merged.
Extend PDL.TASK.HANDOFF_REPORT:
Report that ta4j is public and the PR is intentionally left open for public viewing and human merge.
Override QAS.TASK.MERGE_POLICY:
ta4j requires a human viewing opportunity before `master` changes. Automation prepares, verifies, monitors, fixes feedback, resolves conversations, and hands off the ready PR.
Require QAS.TASK.FINAL_PR_SWEEP:
Before handoff, perform one live final sweep for top-level comments, actionable reviews, and unresolved review threads.
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
# Ta4j Release Process
This document is the operator guide for shipping ta4j releases safely and repeatedly.
Default branch: `master`
Core release invariant:
- the release tag must point to the intended release commit
- that release commit must be reachable from `master`
---
## 1. At a Glance
Preferred path (PR mode):
1. Scheduler (`release-scheduler.yml`) builds a release dossier, validates the configured GitHub Models catalog entry, asks the model for a SemVer decision, and dispatches prepare when appropriate.
2. Prepare release (`prepare-release.yml`) creates release + next-snapshot commits on `release/<version>`.
3. Maintainer reviews and merges release PR into `master` using a **merge commit**.
4. Publish release (`publish-release.yml`) validates metadata/ancestry, runs the release-candidate gate, validates the artifact manifest, tags, deploys to Maven Central, pushes tag, and explicitly dispatches snapshot publication.
5. GitHub release (`github-release.yml`) runs on tag push.
6. Release health (`release-health.yml`) audits drift, verifies the current snapshot version is published once snapshot publication is authoritative, and posts summary.
Emergency path (direct push mode):
1. `prepare-release.yml` pushes both commits directly to `master` when `RELEASE_DIRECT_PUSH=true`.
2. It then dispatches `publish-release.yml`.
Validation path (dry run):
1. Manually run any mutating release workflow and keep its default `dryRun=true`.
2. Inspect the computed release/tag/snapshot values, workflow summary, and audit artifacts.
3. Rerun manually with `dryRun=false` only when intentionally mutating, or let the scheduled/push/merge release path run with its explicit `dryRun=false`.
4. Prepare dry-runs still run the read-only release-ready and next-cycle deprecation scans and upload their reports.
5. No release commits, managed cleanup issue mutations, branch/tag push, Maven Central deploy, GitHub Release creation, snapshot deploy, or discussion/comment mutation occur.
---
## 2. Release Modes
| Mode | How it starts | Git changes | Tag/deploy | Typical use |
|---|---|---|---|---|
| PR mode (default) | scheduler or manual prepare | release branch + merged PR | yes | normal production releases |
| Direct push | manual prepare + `RELEASE_DIRECT_PUSH=true` | commits land directly on `master` | yes | emergency/unblocked maintenance |
| Dry run | manual mutating release workflow, default `dryRun=true` | none | no | inspection-first preflight validation |
---
## 3. Preconditions (Do This Before Releasing)
Code and docs:
1. Update `CHANGELOG.md` under `Unreleased`.
2. Keep README version references current.
3. Run `scripts/docs-integrity-check.sh` and resolve failures before preparing a release.
- This gate verifies canonical docs presence, link validity, command references, and TODO-free user-facing entry docs.
4. Ensure release notes can be generated cleanly (`release/<version>.md`).
5. Confirm release PRs are visible to release owner:
- release PR must be labeled `release`.
- release PR is auto-assigned to `TheCookieLab`.
- `TheCookieLab` is auto-requested as reviewer.
6. While any labeled release PR is open, only that release PR can be merged.
7. Open PRs will receive an automatic release-freeze notice comment while the freeze is active. The workflow removes that notice once no release PR remains open.
Repository settings:
1. Actions workflow permissions must allow write operations (dispatch, PR updates, tag push).
2. Use merge-commit discipline for release PRs (no squash/rebase for those PRs).
3. Add `Release Merge Freeze` as a required status check on `master` in branch protection.
Secrets and variables:
1. Required secrets for publish/snapshot must be configured.
2. Scheduler-related variables must be set intentionally (especially `RELEASE_SCHEDULER_ENABLED`).
---
## 4. Production Runbook (PR Mode)
1. Trigger **Prepare Release**
- Via scheduler or manual `workflow_dispatch`.
- Inputs: `releaseVersion` (optional if auto-detected), `nextVersion` (optional), `dryRun`.
- Manual runs default `dryRun=true`. Set `dryRun=false` only for an intentional mutating run after reviewing a dry-run. Scheduled release automation dispatches prepare with explicit `dryRun=false`.
- For scheduled production runs, confirm `release-scheduler.yml` logs `audit:dry_run_normalized=false` and `audit:dispatch_workflow workflow=prepare-release.yml ... dryRun=false`, then confirm `prepare-release.yml` logs `audit:dry_run_input_raw='false'` and `audit:dry_run_normalized=false`.
- If `nextVersion` is omitted and `releaseVersion` is a plain `X.Y.Z`, it is auto-generated as `<major>.<minor>.<patch+1>-SNAPSHOT` (for example `0.22.2` -> `0.22.3-SNAPSHOT`).
- For RC/non-plain release versions, provide `nextVersion` explicitly.
- Before the workflow commits the next snapshot version, it runs the Java-based `ta4jexamples.doc.RemovalReadyDeprecationScanner` against the release version and fails if any `@Deprecated(forRemoval = true)` symbols are due or overdue for removal. This read-only scan also runs in dry-run mode.
- After the next snapshot version is known, it scans sources scheduled for that planned snapshot or any earlier removal version because ta4j versions may jump across major, minor, or patch positions. Non-dry-run runs then sync deduplicated GitHub cleanup issues, including reopening still-valid managed issues and closing stale managed issues for the same removal version; dry-runs only upload the scan report.
- The workflow uploads release-gate and next-snapshot removal-ready deprecation report artifacts with grouped findings, symbols, lifecycle status, replacement hints when available, and synced issue links when issue sync runs.
- The scanner JSON is the stable handoff contract for future automation: it includes `schemaVersion`, `automationNamespace`, grouped issue `planKind`, and per-symbol `trackingKey` fields so a later AI-driven planner can split work into one issue per deprecated item while remaining restart-safe by searching managed markers before mutation.
- The workflow auto-labels the PR with `release`, assigns it to `TheCookieLab`, and requests review from `TheCookieLab`.
- In PR mode, the successful handoff is the release branch push plus release PR creation. In direct-push mode, the successful handoff is `prepare-release.yml` dispatching `publish-release.yml` with `dryRun=false`.
- Opening a release PR automatically triggers freeze notices on other open PRs.
2. Review generated release PR
- Confirm release commit + next snapshot commit are present.
- Confirm release notes file exists (`release/<version>.md`).
- Confirm docs delta is complete for changed APIs/examples (README/wiki/examples index/changelog consistency).
- Confirm canonical documentation artifacts are present and linked (`Home`, `Getting-started`, journey/runbook/checklist/troubleshooting, decision matrix, migration map, expected outputs, performance characterization).
- If a release PR is open, wait for it to merge before merging any non-release PRs to `master`.
3. Merge release PR to `master`
- Use a **merge commit**.
- Do not squash/rebase release PRs.
- This merge is allowed because the `Release Merge Freeze` check allows only release PRs to merge during freeze.
4. Observe **Publish Release**
- Workflow validates release metadata and ancestry.
- Creates annotated tag.
- Deploys to Maven Central.
- Pushes tag only after successful deploy.
- Verifies pushed tag points to expected release commit and is reachable from default branch.
5. Observe post-publish workflows
- `github-release.yml` should run from tag push.
- `release-health.yml` may first report snapshot publication as pending during the async handoff, then should report `OK` after `snapshot.yml` completes.
---
## 5. Dry-Run Runbook
Manual release workflows are inspection-first. `release-scheduler.yml`, `prepare-release.yml`, `publish-release.yml`, `github-release.yml`, `snapshot.yml`, and `release-health.yml` all default manual `workflow_dispatch` runs to `dryRun=true`.
Operator flow:
1. Run the workflow manually and leave `dryRun=true`.
2. Inspect the computed values in the workflow summary and audit artifacts: release version, next snapshot, tag, publish target, snapshot version, and planned mutation steps.
3. If the computed values are correct and mutation is intended, rerun the same workflow with `dryRun=false`.
4. If no manual mutation is needed, let the official scheduled, push, merge, tag-push, or workflow-run trigger continue; those paths normalize to `dryRun=false`.
Prepare dry-runs may leave `releaseVersion` and `nextVersion` blank where auto-detection is supported. Publish dry-runs require `releaseVersion`; `releaseCommit` remains optional and can be auto-detected.
Expected behavior:
- dry-run can warn about missing deploy secrets/resources.
- no managed cleanup issue sync, release PR creation, branch push, tag push, Maven Central deployment, GitHub Release creation, snapshot deployment, or discussion/comment mutation.
- prepare dry-runs still run push capability probes with `git push --dry-run`.
- prepare dry-runs run deprecation scans and upload report artifacts, but skip managed GitHub cleanup issue sync. If the release-ready gate finds due or overdue removals, the dry-run fails after the reports are available.
- publish dry-runs run the same metadata, ancestry, release-candidate, and artifact manifest checks without deploying. If the release tag already exists, the dry-run records a warning and continues so historical release validation remains possible.
- GitHub Release dry-runs build and validate the exact release artifact manifest from the selected tag while using workflow support files from the workflow commit.
- release-candidate checks use the repository default `integration,slow` test-tag exclusions and log that policy in the workflow output.
- workflows upload audit artifacts such as release dossiers, decisions, manifests, logs, and tag-resolution files so failures can be diagnosed from the exact phase that produced them.
---
## 6. Workflow Map (Trigger + Responsibility)
| Workflow | Trigger(s) | Primary responsibility | Critical guardrails |
|---|---|---|---|
| `release-scheduler.yml` | schedule, manual | decide whether/how to release | manual runs default dry-run; schedule normalizes to production; binary-impact gate, model catalog preflight, release dossier, semver safety, tag collision checks |
| `prepare-release.yml` | manual (or scheduler dispatch) | generate release artifacts and release PR/direct-push commits | manual runs default dry-run; scheduler passes `dryRun`; docs-integrity checks, version validation, metadata validation, dry-run push capability probes |
| `publish-release.yml` | merged release PR close, manual | release-candidate verification + tag + Maven Central deploy + snapshot dispatch + release summary | manual runs default dry-run; merged release PRs normalize to production; merge discipline + ancestry checks, artifact manifest checks, post-push tag integrity/reachability checks |
| `release-health.yml` | push to `master`, publish workflow completion, snapshot workflow completion, schedule, manual | detect drift in release state | manual runs default dry-run; non-manual triggers normalize to production; fails on tag reachability drift, snapshot version drift, missing snapshot publication once snapshot publication is authoritative, missing notes, stale release PRs |
| `github-release.yml` | semver-like tag push, manual | GitHub release publication | manual runs default dry-run; tag pushes normalize to production; semver tag validation, exact artifact manifest using current workflow support files |
| `snapshot.yml` | push to `master`, publish workflow dispatch, manual | publish snapshots | manual runs default dry-run; master pushes and publish handoff normalize to production; build/test/deploy prechecks and source-release audit fields |
Tag metrics used by release automation:
- `latest tag`: newest release tag by tag creation date, preferring bare numeric tags before `v`-prefixed tags.
- `last reachable tag`: newest release tag merged into `master`; `release-scheduler.yml` uses this as its diff and version baseline.
- `last first-parent tag`: nearest release tag visible on `master`'s first-parent chain; informational only for diagnosing merge topology.
- `latest tag reachable`: whether the newest release tag is an ancestor of `master`.
---
## 7. Verification Checklist (Post-Release)
1. `prepare-release.yml` and `publish-release.yml` succeeded.
2. Tag exists for released version.
3. Tag reachability from `master` is true.
4. Maven Central artifacts are visible (allow propagation time).
5. GitHub release exists with expected notes/artifacts.
6. Release-ready deprecation gate report exists for the released version and did not find due or overdue removals.
7. Removal-ready deprecation report artifact exists for the new snapshot version, due or overdue removal versions were included, and any matching cleanup issues were created, refreshed, reopened, or closed as stale successfully.
8. The chained `snapshot.yml` run succeeded for the next `-SNAPSHOT` version.
9. `master` is on next `-SNAPSHOT` version.
10. `release-health.yml` reports no drift and confirms the current snapshot version is published after `snapshot.yml` completes.
Quick checks:
```bash
git fetch origin --tags
VERSION=0.22.3
git rev-list -n 1 "refs/tags/${VERSION}"
git merge-base --is-ancestor "${VERSION}" origin/master && echo "reachable"
```
---
## 8. Troubleshooting
### 8.1 Publish succeeded, Health failed
Is this possible?
- Yes. `publish-release.yml` and `release-health.yml` are separate workflows.
What should fail earlier now?
- If the just-pushed tag is wrong/unreachable, publish should fail first due to post-push tag checks.
Why health can still fail afterward:
- `pom.xml` snapshot not ahead of latest tag.
- current snapshot version is missing from the Maven snapshot repository after snapshot publication becomes authoritative.
- missing `release/<version>.md` for latest tag.
- stale release PRs.
- existing repository drift not introduced by this publish run.
Why a large `last first-parent tag` gap can still be healthy:
- release PRs merge back to `master` with merge commits, so the tagged release commit often lives on the merge's second parent rather than the first-parent spine.
- `release-health.yml` only fails when the newest release tag is not reachable from `master`; a lagging first-parent tag is diagnostic context, not drift by itself.
Remediation playbook:
1. Open the failed `release-health.yml` run and read `Drift reasons`.
2. Apply targeted fix:
- `latest tag not reachable from <branch>`: make tagged commit reachable from `master` (typically a reachability merge commit; avoid retagging published releases).
- `pom.xml snapshot version not ahead of latest tag`: bump `master` to next `-SNAPSHOT`.
- `current snapshot version not published to Maven snapshot repository`: inspect the latest `snapshot.yml` run, fix the publication failure, and rerun health after the snapshot version appears in metadata.
- `missing release notes for latest tag`: add `release/<version>.md`.
- `stale release PRs detected`: merge or close stale release PRs.
3. Re-run health via `workflow_dispatch`.
4. If Maven deploy already succeeded, treat this as repo-state remediation, not republish.
### 8.2 Release PR waiting for merge
- Ensure required checks pass.
- Ensure required maintainer approval exists.
- Merge with merge commit.
### 8.3 Tag exists error
- Version already tagged.
- Use a new version unless you have an explicit rollback/recovery plan.
### 8.4 Release notes missing
- Ensure `release/<version>.md` exists in the release commit.
### 8.5 Branch advanced during prepare/push
- `master` moved while workflow was preparing/pushing.
- Re-run prepare once branch is stable.
### 8.6 Dry-run warnings
- Missing deploy secrets in dry-run are expected warnings.
- Fix before production run.
### 8.7 Finding the failure point
Release workflows use grouped log sections and upload audit artifacts on every run.
Look for these files first:
- `release-dossier.md`: scheduler context sent to the model.
- `release-decision.json`: normalized AI release decision.
- `release-audit.json`: workflow-local release metadata.
- `tag-resolution.txt`: resolved latest/reachable/first-parent tag state.
- `artifact-manifest.txt`: exact jars expected for publish/GitHub Release.
- `javadoc-warnings.txt`: Javadoc warning baseline comparison from release artifact/deploy logs; new warnings beyond the tracked `scripts/release/javadoc-warning-baseline.txt` debt fail publish.
- `.agents/logs/full-build-*.log`: release-candidate full build log.
If a grouped section fails, inspect the matching artifact before rerunning. Avoid rerunning publish until tag state, artifact state, and Central deployment state are understood.
---
## 9. Discussion Posts (Markers and Cleanup)
Non-dry-run release-related workflows post to GitHub Discussions with machine-readable markers:
```html
<!-- ta4j:post-type=<type>;run=<real|dry-run> -->
```
Post types:
- `release-scheduler`
- `publish-release`
- `release-health`
Cleanup rules:
- manual dry-runs do not create, update, or delete discussion comments; their dry-run details remain in workflow summaries and audit artifacts.
- `release-health`: removes prior real health posts before posting the latest real summary.
- `release-scheduler`: posts real scheduler summaries only.
- `publish-release`: posts real publish summaries and keeps historical posts as an audit trail.
Do not key automation off author/body heuristics; key off marker metadata.
---
## 10. Required Resources
### 10.1 Required secrets
| Secret | Used by | Purpose |
|---|---|---|
| `MAVEN_CENTRAL_TOKEN_USER` | publish, snapshot | Maven Central auth user/token |
| `MAVEN_CENTRAL_TOKEN_PASS` | publish, snapshot | Maven Central auth password/token |
| `GPG_PRIVATE_KEY` | publish, snapshot | artifact signing key |
| `GPG_PASSPHRASE` | publish, snapshot | signing key passphrase |
| `GH_MODELS_TOKEN` | scheduler | model API access |
| `GH_TA4J_REPO_TOKEN` | prepare, publish, github-release | PAT for release pushes/release creation |
### 10.2 Optional secrets
| Secret | Used by | Purpose |
|---|---|---|
| `MAVEN_MASTER_PASSPHRASE` | publish, snapshot | optional Maven settings-security |
### 10.3 Variables
| Variable | Used by | Purpose |
|---|---|---|
| `RELEASE_DIRECT_PUSH` | prepare | direct-push mode switch |
| `RELEASE_NOTIFY_USER` | publish, scheduler, health | discussion mention target |
| `RELEASE_DISCUSSION_NUMBER` | publish | publish summary discussion |
| `RELEASE_SCHEDULER_DISCUSSION_NUMBER` | scheduler, health | scheduler/health discussion |
| `RELEASE_SCHEDULER_ENABLED` | scheduler | enable scheduled release decisions |
| `RELEASE_AI_MODEL` | scheduler | GitHub Models model override; default should be `openai/gpt-4.1` |
| `RELEASE_PR_STALE_DAYS` | health | stale release PR threshold |
### 10.4 Environment
| Environment | Purpose |
|---|---|
| `major-release` | manual approval gate for major bumps |
| `release-recovery` | manual approval gate for `publish-release.yml` recovery mode when an explicit release commit is not reachable from `master` |
---
## 11. Repository Policy Notes
1. Keep release tags reachable from `master`.
2. Use merge commits for release PRs.
3. Avoid force-retagging already-published versions.
4. Prefer PR mode; use direct push only when justified.
5. Keep `RELEASE_DIRECT_PUSH=false` for normal releases.
6. Use `recoveryMode=true` only for manual publish recovery when the release commit is intentionally not reachable from `master`; this path is protected by the `release-recovery` environment.
---
## 12. Responsibilities Summary
| Task | Maintainer/manual | GitHub Actions |
|---|---|---|
| maintain changelog/readme release readiness | yes | no |
| generate release notes | yes (via prepare script/workflow) | yes |
| set release version + next snapshot | no | yes |
| create/push release tag | no | yes (non-dry-run) |
| deploy to Maven Central | no | yes (non-dry-run) |
| publish GitHub release | no | yes (non-dry-run) |
| release drift auditing | no | yes |
---
## 13. RC Builds
RC versions are supported if they are Maven-valid (for example `0.22.2-rc1`).
Use the same workflows and verification checklist.
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
ta4j is a Java 21+ library. Most risk appears when it is embedded in larger systems. **JSON strategy/indicator restore** and **Java (de)serialization of bar series** require safe handling of untrusted inputs.
## Reporting a vulnerability
**Do not open a public GitHub Issue for security-sensitive reports.**
- **Preferred:** [Report a vulnerability](https://github.com/ta4j/ta4j/security) (GitHub private reporting).
- **Fallback:** Open a **draft** GitHub Discussion with minimal contact details and ask maintainers to move it to a private channel.
Include: affected module(s) and version(s), Java runtime, minimal reproduction (snippet or test), impact and preconditions, and optional mitigation ideas. A deterministic unit test speeds things up.
## Supported versions
- **Supported:** latest stable release and `master` (snapshot). Security fixes ship from `master` into the next release.
- **Best-effort:** older releases (no release branches; upgrade recommended).
- **Unsupported:** archived or unmaintained forks.
## Scope
**In scope:** unsafe deserialization / gadget chains, DoS or resource exhaustion, data exposure or integrity violations, supply-chain or build issues, vulnerabilities in `ta4j-examples`.
**Out of scope:** purely mathematical bugs with no security impact, trading performance claims, “my bot lost money” unless due to an exploitable integrity issue. Unsure? Report privately; maintainers will triage.
## Guidance for users
- **Do not deserialize untrusted data.** Avoid Java serialization for user-supplied payloads; if you must, use strong input controls and prefer JSON restore with strict validation.
- **Treat strategy/indicator JSON as untrusted:** validate types, bound parameters and bar count, enforce compute limits.
- **Cap compute:** limit max bars per request, avoid unbounded caches keyed by user input, use timeouts around backtests.
## Process
We aim to acknowledge within 7 days, fix on `master` with tests, then release and publish a Security Advisory (and CVE if appropriate). Default target: ≤90 days to public disclosure; critical issues may be accelerated.
GitHub CodeQL, Dependabot, and dependency submission are in use; they are not a substitute for private disclosure of real vulnerabilities.
No bug bounty. We will credit reporters in advisories if you want (tell us your preferred name/handle).
+322
View File
@@ -0,0 +1,322 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<profiles version="15">
<profile kind="CodeFormatterProfile" name="JavaConventions" version="15">
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_ellipsis" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_for_statment" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_invocation" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_switch_statement" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_enum_constant_declaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_with_spaces" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.disabling_tag" value="@formatter:off"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_enum_constants" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_binary_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_if_while_statement" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_root_tags" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.enabling_tag" value="@formatter:on"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.count_line_length_from_starting_position" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameterized_type_references" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_block" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.align_tags_descriptions_grouped" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.keep_enum_constant_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.align_variable_declarations_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_binary_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_catch_clause" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.compact_else_if" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_type_parameters" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_loops" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_binary_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_annotation" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_ellipsis" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_type_members_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_module_statements" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.align_tags_names_descriptions" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_assignment_statements_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_method_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_conditional_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_fields_grouping_blank_lines" value="2147483647"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_try_clause" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_source_code" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_assignment_operator" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_html" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_delcaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_if" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.indent_empty_lines" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_type_arguments" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_label" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_body" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiple_fields" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_binary_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_lambda_declaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_for_loop_header" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_getter_setter_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.join_lines_in_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_import_groups" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch" value="insert"/>
</profile>
</profiles>
+2
View File
@@ -0,0 +1,2 @@
SPDX-License-Identifier: MIT
+523
View File
@@ -0,0 +1,523 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.ta4j</groupId>
<artifactId>ta4j-parent</artifactId>
<version>0.22.7-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Ta4j Parent</name>
<description>ta4j is a Java library providing a simple API for technical analysis.</description>
<url>https://github.com/ta4j/ta4j</url>
<inceptionYear>2014</inceptionYear>
<developers>
<developer>
<id>mdeverdelhan</id>
<name>Marc de Verdelhan</name>
</developer>
<developer>
<id>team172011</id>
<name>Simon-Justus Wimmer</name>
</developer>
<developer>
<id>TheCookieLab</id>
<name>David Pang</name>
</developer>
</developers>
<licenses>
<license>
<name>MIT License</name>
<comments>All source code is under the MIT license.</comments>
</license>
</licenses>
<issueManagement>
<system>GitHub</system>
<url>https://github.com/ta4j/ta4j/issues</url>
</issueManagement>
<distributionManagement>
<snapshotRepository>
<id>central</id>
<name>Central Portal Snapshot Repository</name>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
<scm>
<connection>scm:git:git://github.com/ta4j/ta4j.git</connection>
<developerConnection>scm:git:git@github.com:ta4j/ta4j.git</developerConnection>
<url>https://github.com/ta4j/ta4j</url>
<tag>HEAD</tag>
</scm>
<modules>
<module>ta4j-core</module>
<module>ta4j-examples</module>
</modules>
<properties>
<!-- Encoding -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- Build -->
<maven.compiler.release>25</maven.compiler.release>
<maven.surefire.version>3.5.4</maven.surefire.version>
<ta4j.excludedTestTags>integration,analysis-demo,elliott-macro-cycle-replay</ta4j.excludedTestTags>
<spotbugs.version>4.9.8.0</spotbugs.version>
<jacoco.version>0.8.13</jacoco.version>
<ta4j.jacoco.line.minimum>0.80</ta4j.jacoco.line.minimum>
<ta4j.jacoco.branch.minimum>0.80</ta4j.jacoco.branch.minimum>
<!-- Dependencies -->
<assertj.version>3.27.7</assertj.version>
<commons-math3.version>3.6.1</commons-math3.version>
<gson.version>2.13.2</gson.version>
<jfreechart.version>1.5.6</jfreechart.version>
<junit-jupiter.version>6.0.2</junit-jupiter.version>
<log4j2.version>2.25.4</log4j2.version>
<mockito.version>5.21.0</mockito.version>
<opencsv.version>5.12.0</opencsv.version>
<poi.version>5.5.1</poi.version>
<slf4j.version>2.0.17</slf4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
<version>${commons-math3.version}</version>
</dependency>
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>${opencsv.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>${poi.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jfree</groupId>
<artifactId>jfreechart</artifactId>
<version>${jfreechart.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<profiles>
<!-- Run Elliott-wave analysis demo tests intentionally with explicit opt-in -->
<profile>
<id>analysis-demo</id>
<properties>
<ta4j.excludedTestTags>elliott-macro-cycle-replay</ta4j.excludedTestTags>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>analysis-demo</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<!-- Only when performing a release (i.e. not for snapshots) -->
<profile>
<id>production-release</id>
<build>
<plugins>
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.10.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.12.0</version>
<configuration>
<doclint>none</doclint>
</configuration>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Artifact signing -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.8</version>
<configuration>
<!-- Prevent gpg from using pinentry programs -->
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<!-- Sign snapshots when deploying -->
<profile>
<id>sign-snapshots</id>
<build>
<plugins>
<!-- Artifact signing -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.8</version>
<configuration>
<!-- Prevent gpg from using pinentry programs -->
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>${spotbugs.version}</version>
<configuration>
<quiet>true</quiet>
<xmlOutput>true</xmlOutput>
</configuration>
<executions>
<execution>
<id>spotbugs-check</id>
<phase>verify</phase>
<configuration>
<failOnError>false</failOnError>
</configuration>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>${ta4j.jacoco.line.minimum}</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>${ta4j.jacoco.branch.minimum}</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
<executions>
<execution>
<id>jacoco-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>jacoco-check</id>
<phase>verify</phase>
<configuration>
<haltOnFailure>false</haltOnFailure>
</configuration>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>enforce-build-environment</id>
<phase>validate</phase>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireMavenVersion>
<version>[3.9.0,)</version>
</requireMavenVersion>
<requireJavaVersion>
<version>[25,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<!-- Build source and target -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>${maven.compiler.release}</release>
<parameters>true</parameters>
<showDeprecation>true</showDeprecation>
<showWarnings>true</showWarnings>
<compilerArgs>
<arg>-proc:full</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire.version}</version>
<configuration>
<excludedGroups>${ta4j.excludedTestTags}</excludedGroups>
<useModulePath>false</useModulePath>
<argLine>
@{argLine}
-XX:+EnableDynamicAgentLoading
-Xshare:off
-Djdk.instrument.traceUsage=false
</argLine>
</configuration>
</plugin>
<!-- Package sources -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- License headers -->
<plugin>
<groupId>com.mycila</groupId>
<artifactId>license-maven-plugin</artifactId>
<version>5.0.0</version>
<configuration>
<licenseSets>
<licenseSet>
<header>${project.basedir}/license-header.txt</header>
<includes>
<include>**/*.java</include>
</includes>
</licenseSet>
</licenseSets>
</configuration>
</plugin>
<!-- Source formatter -->
<plugin>
<groupId>net.revelc.code.formatter</groupId>
<artifactId>formatter-maven-plugin</artifactId>
<version>2.29.0</version>
<configuration>
<configFile>${project.basedir}/code-formatter.xml</configFile>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<inherited>false</inherited>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<!-- Releases -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<tagNameFormat>@{project.version}</tagNameFormat>
</configuration>
<dependencies>
<dependency>
<groupId>org.apache.maven.shared</groupId>
<artifactId>maven-invoker</artifactId>
<version>3.3.0</version>
</dependency>
</dependencies>
</plugin>
<!-- Version plugin -->
<!-- mvn versions:display-dependency-updates -->
<!-- mvn versions:display-plugin-updates -->
<!-- mvn versions:display-property-updates -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>versions-maven-plugin</artifactId>
<version>2.21.0</version>
<configuration>
<ruleSet>
<ignoreVersions>
<ignoreVersion>
<type>regex</type>
<version>(?i).*-SNAPSHOT.*</version>
</ignoreVersion>
<ignoreVersion>
<type>regex</type>
<version>(?i).*(?:-alpha(?:[.-]?\d+)?|-beta(?:[.-]?\d+)?).*</version>
</ignoreVersion>
<ignoreVersion>
<type>regex</type>
<version>(?i).*(?:-milestone(?:[.-]?\d+)?|-m\d+).*</version>
</ignoreVersion>
<ignoreVersion>
<type>regex</type>
<version>(?i).*(?:-rc(?:[.-]?\d+)?|-cr(?:[.-]?\d+)?).*</version>
</ignoreVersion>
</ignoreVersions>
</ruleSet>
</configuration>
</plugin>
</plugins>
</build>
</project>
+65
View File
@@ -0,0 +1,65 @@
## 0.19 (released November 19, 2025)
### Breaking
- **`TradingStatement` is now an interface**: Converted to an interface implemented by `BaseTradingStatement`. This exposes the underlying `Strategy` and `TradingRecord` for advanced analysis workflows. **Action required**: Update any code that directly instantiates `TradingStatement` to use `BaseTradingStatement` instead.
- **PnL and return criteria refactored into net/gross variants**: Split `ProfitLossCriterion`, `ProfitCriterion`, `LossCriterion`, `AverageProfitCriterion`, `AverageLossCriterion`, `ReturnCriterion`, `ProfitLossRatioCriterion`, and `ProfitLossPercentageCriterion` into separate net and gross concrete classes. This provides explicit control over whether trading costs are included in calculations. **Action required**: Update imports and class names to use the appropriate net or gross variant based on your analysis needs.
- **Indicator operation classes consolidated**: [#1266](https://github.com/ta4j/ta4j/issues/1266) Unified `BinaryOperation`, `UnaryOperation`, `TransformIndicator`, and `CombineIndicator` into a cleaner API. **Action required**: Replace deprecated `TransformIndicator` and `CombineIndicator` usage with the new consolidated classes.
- **Drawdown criteria moved to sub-package**: Relocated `MaximumDrawdownCriterion` and `ReturnOverMaxDrawdownCriterion` to the `criteria/drawdown/` sub-package for better organization. **Action required**: Update import statements to reflect the new package location.
### Added
- **Rule naming support**: Added `Rule#getName()` and `Rule#setName(String)` methods to allow rules to have custom names for improved trace logging and serialization. Rules now default to JSON-formatted names that include type and component information, but can be overridden with custom labels for better readability in logs and debugging output.
- **Time-based trading rules**: Added `HourOfDayRule` and `MinuteOfHourRule` to enable trading strategies based on specific hours of the day (0-23) or minutes of the hour (0-59). These rules work with `DateTimeIndicator` to filter trading signals by time, enabling time-of-day based strategies.
- **Time-based strategy examples**: Added `HourOfDayStrategy` and `MinuteOfHourStrategy` as example implementations demonstrating how to use the new time-based rules in complete trading strategies.
- **Enhanced backtesting with performance tracking**: Introduced `BacktestExecutionResult` and `BacktestRuntimeReport` with new `BacktestExecutor` entry points. Users can now track per-strategy execution times, receive progress callbacks during long-running backtests, and efficiently stream top-k strategy selection for large strategy grids without loading all results into memory.
- **Strategy serialization for persistence**: Added `StrategySerialization` with `Strategy#toJson()` and `Strategy#fromJson(BarSeries, String)` methods. This enables users to save and restore complete strategy configurations (including entry/exit rules) as JSON, making it easy to share strategies, version control configurations, and build strategy libraries.
- **NamedStrategy serialization with compact format**: [#1349](https://github.com/ta4j/ta4j/issues/1349) Enabled `NamedStrategy` serialization/deserialization with compact labels (e.g., `ToggleNamedStrategy_true_false_u3`). Users can now persist strategy presets alongside their parameters in a human-readable format. Added registry/permutation helper APIs and lazy package scanning via `NamedStrategy.initializeRegistry(...)` for efficient strategy discovery.
- **Renko chart indicators**: [#1187](https://github.com/ta4j/ta4j/issues/1187) Added `RenkoUpIndicator`, `RenkoDownIndicator`, and `RenkoXIndicator` to detect Renko brick sequences, enabling users to build strategies based on Renko chart patterns.
- **Advanced drawdown analysis**: Added `CumulativePnL`, `MaximumAbsoluteDrawdownCriterion`, `MaximumDrawdownBarLengthCriterion`, and `MonteCarloMaximumDrawdownCriterion`. Users can now analyze drawdowns in absolute terms, measure drawdown duration, and estimate drawdown risk distributions through Monte Carlo simulation of different trade orderings.
- **Comprehensive commission tracking**: Added `CommissionsCriterion` to total commissions paid across positions and `CommissionsImpactPercentageCriterion` to measure how much trading costs reduce gross profit. This helps users understand the real impact of transaction costs on strategy performance.
- **Streak and extreme position analysis**: Added `MaxConsecutiveLossCriterion`, `MaxConsecutiveProfitCriterion`, `MaxPositionNetLossCriterion`, and `MaxPositionNetProfitCriterion`. Users can now identify worst loss streaks, best win streaks, and extreme per-position outcomes to better understand strategy risk and consistency.
- **Position timing analysis**: Added `InPositionPercentageCriterion` to calculate the percentage of time a strategy remains invested, helping users understand capital utilization and exposure.
- **Flexible bar building options**: Added `AmountBarBuilder` to aggregate bars after a fixed number of amount have been traded. Bars can now be built by `beginTime` instead of `endTime`, providing more flexibility in bar aggregation strategies.
- **Volume-weighted MACD**: Added `MACDVIndicator` to volume-weight MACD calculations, providing an alternative MACD variant that incorporates volume information.
- **Net momentum indicator**: Added `NetMomentumIndicator` for momentum-based strategy development.
- **Vote-based rule composition**: Added `VoteRule` class, enabling users to create rules that trigger based on majority voting from multiple underlying rules.
- **Enhanced data loading**: Added `AdaptiveJsonBarsSerializer` to support OHLC bar data from Coinbase or Binance, and new `JsonBarsSerializer.loadSeries(InputStream)` overload for easier data loading from streams.
- **Improved charting and examples**: Expanded charting utilities to overlay indicators with trading records, added `NetMomentumStrategy` and `TopStrategiesExample`, and bundled a Coinbase ETH/USD sample data set to demonstrate the new APIs.
- **Automated release pipeline**: Added GitHub workflow to automatically version, build, and publish artifacts to Maven Central. The pipeline uses `prepare-release.sh` to prepare release versions, creates release branches and tags, and publishes to Maven Central. Added `scripts/tests/test_prepare_release.sh` to validate release preparation functionality.
- **Enhanced performance reporting**: Added Gson `DurationTypeAdapter`, `BasePerformanceReport`, and revised `TradingStatementGenerator` so generated statements always carry their source strategy and trading record for complete traceability.
- **UnaryOperation helper**: Added `substitute` helper function to `UnaryOperation` for easier indicator transformations.
- **Testing infrastructure**: Added tests for `DoubleNumFactory` and `DecimalNumFactory`, unit tests around indicator concurrency in preparation for future multithreading features, and `DecimalNumPrecisionPerformanceTest` to demonstrate precision vs performance trade-offs.
### Changed
- **Enhanced rule serialization with custom name preservation**: Improved `RuleSerialization` to preserve custom rule names set via `setName()` during serialization and deserialization. Custom names are now properly distinguished from default JSON-formatted names, enabling better strategy persistence and debugging workflows.
- **Improved trace logging with rule names**: Enhanced trace logging in `AbstractRule` and `BaseStrategy` to use rule names (custom or default) in log output, making it easier to identify which rules are being evaluated during strategy execution.
- **Unified logging backend**: Replaced Logback bindings with Log4j 2 `log4j-slf4j2-impl` so examples and tests share a single logging backend. Added Log4j 2 configurations for modules and tests. This simplifies logging configuration and ensures consistent behavior across all modules. Set unit test logging level to INFO and cleaned build output of all extraneous logging.
- **More accurate return calculations**: Changed `AverageReturnPerBarCriterion`, `EnterAndHoldCriterion`, and `ReturnOverMaxDrawdownCriterion` to use `NetReturnCriterion` instead of `GrossReturnCriterion` to avoid optimistic bias. This provides more realistic performance metrics that account for trading costs.
- **Improved drawdown criterion behavior**: `ReturnOverMaxDrawdownCriterion` now returns 0 instead of `NaN` for strategies that never operate, and returns net profit instead of `NaN` for strategies with no drawdown. This makes the criterion more robust and easier to use in automated analysis.
- **More flexible stop rules**: `StopGainRule` and `StopLossRule` now accept any price `Indicator` instead of only `ClosePriceIndicator`. Users can now create stop rules based on high, low, open, or custom price indicators for more sophisticated exit strategies.
- **Enhanced swing indicators**: Reworked `RecentSwingHighIndicator` and `RecentSwingLowIndicator` with plateau-aware, NaN-safe logic and exposed `getLatestSwingIndex` for downstream analysis. This improves reliability and enables more advanced swing-based strategies.
- **Configurable numeric precision**: Reduced default `DecimalNum` precision from 32 to 16 digits, improving performance while still maintaining sufficient accuracy for most use cases. Users can configure precision based on their specific needs.
- **Improved numeric indicator chaining**: `NumericIndicator`'s `previous` method now returns a `NumericIndicator`, enabling fluent method chaining for indicator composition.
- **Enhanced trading statements**: Added `TradingRecord` property to `TradingStatement` for more downstream flexibility around analytics, enabling users to access the full trading record from performance reports.
- **Better code maintainability**: Removed magic number 25 in `UpTrendIndicator` and `DownTrendIndicator`, making the code more maintainable and self-documenting.
- **Modernized build infrastructure**: [#1399](https://github.com/ta4j/ta4j/issues/1399) Refreshed dependencies, plugins, and build tooling while enforcing Java 21 and Maven 3.9+. This ensures compatibility with modern development environments and security updates.
- **Maven Central distribution**: Changed snapshot distribution to Maven Central after OSSRH end-of-life, ensuring continued availability of snapshot builds.
- **Improved bar series builder**: `BaseBarSeriesBuilder` now automatically uses the `NumFactory` from given bars instead of the default one, ensuring consistent numeric types throughout bar series construction.
### Fixed
- **Kalman filter robustness**: Guarded `KalmanFilterIndicator` against NaN/Infinity measurements to keep the Kalman state consistent, preventing calculation errors when input data contains invalid values.
- **Recursive indicator stack overflow**: Fixed recursion bug in `RecursiveCachedIndicator` that could lead to stack overflow in certain situations, improving reliability for complex indicator calculations.
- **Cost tracking in enter-and-hold**: Fixed `EnterAndHoldCriterion` to properly keep track of transaction and hold costs, ensuring accurate performance comparisons.
- **Convergence divergence indicator**: Fixed strict rules of `ConvergenceDivergenceIndicator` for more accurate divergence detection.
- **Return over max drawdown calculation**: Fixed calculation for `ReturnOverMaxDrawdownCriterion` and `VersusEnterAndHoldCriterion` to ensure accurate performance metrics.
- **Profit/loss percentage calculation**: Refactored `ProfitLossPercentageCriterion` to correctly calculate aggregated return, fixing previous calculation errors.
- **Bar series trade parameter order**: Fixed swapped parameter naming in `BaseBarSeries#addTrade(final Number tradeVolume, final Number tradePrice)` to match the method signature order.
- **Bar builder aggregation**: Fixed aggregation of amount and trades in `VolumeBarBuilder` and `TickBarBuilder` to ensure accurate bar construction.
- **SMA unstable period calculation**: Corrected the calculation of unstable bars for the SMA indicator, ensuring indicators report accurate stability periods.
- **Java 25 compatibility**: Fixed `PivotPointIndicatorTest` to work with Java 25, ensuring compatibility with the latest Java versions. Note that this does not mean Ta4j as a whole now supports Java 25, that will come in a future release.
- **JSON data loading**: Fixed bug in `MovingAverageCrossOverRangeBacktest` that prevented successfully loading test JSON bar data.
- **Build performance**: Updated GitHub test workflow to cache dependencies for quicker builds, reducing CI/CD execution time.
- **Documentation**: Updated test status badge on README and clarified PnL criterion comments about trading costs for better user understanding.
### Removed/Deprecated
- **Deprecated indicator classes**: Removed `TransformIndicator` and `CombineIndicator` in favor of the consolidated `BinaryOperationIndicator` and `UnaryOperationIndicator` classes. **Action required**: Migrate any code using these deprecated classes to the new consolidated API.
+28
View File
@@ -0,0 +1,28 @@
## 0.21.0 (2025-11-29)
### Changed
- **Unified return representation system**: Say goodbye to inconsistent return formats across your analysis! Return-based criteria now use a unified `ReturnRepresentation` system that lets you choose how returns are displayed—whether you prefer multiplicative (1.12 for +12%), decimal (0.12), percentage (12.0), or logarithmic formats. Set it once globally via `ReturnRepresentationPolicy` or customize per-criterion. No more mental math converting between formats—Ta4j handles it all automatically. Legacy `addBase` constructors are deprecated in favor of the more expressive `ReturnRepresentation` enum.
- **Ratio criteria now speak your language**: All ratio-producing criteria now support `ReturnRepresentation`, so you can format outputs consistently across your entire analysis pipeline. Whether you're comparing strategies, measuring risk, or tracking performance metrics, everything uses the same format. Updated criteria include:
- `VersusEnterAndHoldCriterion`: Strategy vs. buy-and-hold comparison (e.g., 0.5 = 50% better, displayed as 0.5, 50.0, or 1.5 depending on your preference)
- `ReturnOverMaxDrawdownCriterion`: Reward-to-risk ratio (e.g., 2.0 = return is 2x drawdown)
- `PositionsRatioCriterion`: Win/loss percentage (e.g., 0.5 = 50% winning)
- `InPositionPercentageCriterion`: Time in market (e.g., 0.5 = 50% of time)
- `CommissionsImpactPercentageCriterion`: Trading cost impact (e.g., 0.05 = 5% impact)
- `AbstractProfitLossRatioCriterion` (and subclasses): Profit-to-loss ratio (e.g., 2.0 = profit is 2x loss)
All ratio criteria default to `ReturnRepresentation.DECIMAL` (the conventional format for ratios), but you can override per-criterion or globally. Perfect for dashboards, reports, or when you need to match external data formats. See each criterion's javadoc for detailed examples.
- **Improved return representation tooling**: Added factory-level exponential support to avoid premature double conversions, expanded representation parsing to accept flexible names, and aligned VaR/ES/average-return empty-record behaviour across representations.
- **High-precision DecimalNum exponentials**: `DecimalNumFactory#exp` now evaluates exponentials using the configured `MathContext` instead of delegating to {@code Math.exp}, preventing accidental loss of precision for high-precision numeric workflows.
- **Simplified Returns class implementation**: Removed unnecessary `formatOnAccess` complexity from `Returns` class, inlined trivial `formatReturn()` wrapper method, and improved documentation clarity. The class now has a cleaner separation of concerns with better cross-references between `Returns`, `ReturnRepresentation`, and `ReturnRepresentationPolicy`.
### Breaking
- **EMA indicators now return NaN during unstable period**: `EMAIndicator`, `MMAIndicator`, and all indicators extending `AbstractEMAIndicator` now return `NaN` for indices within the unstable period (indices < `beginIndex + getCountOfUnstableBars()`). Previously, these indicators would return calculated values during the unstable period. **Action required**: Update any code that accesses EMA indicator values during the unstable period to handle `NaN` values appropriately, or wait until after the unstable period before reading values.
- **`DifferencePercentageIndicator` deprecated**: `DifferencePercentageIndicator` has been deprecated in favor of `PercentageChangeIndicator`, which now provides all the same functionality plus additional features. **Action required**: Migrate to `PercentageChangeIndicator` using the migration examples in the deprecation javadoc.
### Added
- Added `TrueStrengthIndexIndicator`, `SchaffTrendCycleIndicator`, and `ConnorsRSIIndicator` to expand oscillator coverage
- Added `PercentRankIndicator` helper indicator to calculate the percentile rank of a value within a rolling window
- Added `DifferenceIndicator` helper indicator to calculate the difference between current and previous indicator values
- Added `StreakIndicator` helper indicator to track consecutive up or down movements in indicator values
- Added `StochasticIndicator` as a generic stochastic calculation indicator, extracted from `SchaffTrendCycleIndicator` for reuse
- **AI-powered semantic release scheduler**: Added automated GitHub workflow that uses AI to analyze changes, determine version bumps (patch/minor/major), and schedule releases every 14 days. Includes structured approval process for major version bumps and OIDC token-based authentication for AI model calls. Enhanced release workflows with improved error handling, tag checking, and logging.
+71
View File
@@ -0,0 +1,71 @@
## 0.22.0 (2025-12-29)
### Breaking Changes
- **CachedIndicator and RecursiveCachedIndicator synchronization changes**: `CachedIndicator` and `RecursiveCachedIndicator` no longer use `synchronized` methods. Thread safety is now achieved through internal locking using `ReentrantReadWriteLock`. **Action required**: Code that relied on external synchronization using indicator instances (e.g., `synchronized(indicator) { ... }`) must be updated to use explicit external locks.
- **SqueezeProIndicator return type change**: `SqueezeProIndicator` return type changed from `Boolean` to `Num`. Use `getSqueezeLevel(int)` or `isInSqueeze(int)` for compression state instead of direct boolean checks.
### Added
- Added GitHub Actions workflow linting (`actionlint`) to catch workflow expression and shell syntax errors early.
- **Num.isValid() utility method**: Added static `Num.isValid(Num)` method as the logical complement of `Num.isNaNOrNull()`, providing a convenient way to check if a value is non-null and represents a real number. Used extensively throughout the Elliott Wave indicators package for robust NaN handling.
- **SqueezeProIndicator correction**: Corrected **SqueezeProIndicator** to mirror TradingView/LazyBear squeeze momentum output: SMA-based Bollinger vs. Keltner compression tiers (high/mid/low), SMA true range width (not Wilder ATR), and the LazyBear detrended-price momentum baseline used by `linreg` (with regression tests on AAPL/NEE 2025-12-05).
- **SuperTrend indicator usability improvements**: Enhanced the SuperTrend indicator suite with intuitive helper methods and comprehensive documentation for traders:
- Added `isUpTrend(int)`, `isDownTrend(int)`, and `trendChanged(int)` methods to `SuperTrendIndicator` for easy trend detection and signal generation without manual band comparisons
- Comprehensive Javadoc with formulas, trading signals, usage examples, and references to authoritative sources (Investopedia, TradingView)
- Added `package-info.java` documentation for the supertrend package
- Expanded unit test coverage with trend reversal scenarios, serialization roundtrip tests, and ratcheting behavior verification
- **Trendline and swing point analysis suite**: Added a comprehensive set of indicators for automated trendline drawing and swing point detection. These indicators solve the common problem of manually identifying support/resistance levels and drawing trendlines by automating the process while maintaining the same logic traders use when drawing lines manually. Useful for breakout strategies, trend-following systems, and Elliott Wave analysis.
- **Automated trendline indicators**: `TrendLineSupportIndicator` and `TrendLineResistanceIndicator` project support and resistance lines by connecting confirmed swing points. The indicators select the best trendline from a configurable lookback window using a scoring system based on swing point touches, proximity to current price, and recency. Historical values are automatically backfilled so trendlines stay straight and anchored on actual pivot points (not confirmation bars), avoiding the visual artifacts common in other implementations. Works with any price indicator (high/low/open/close/VWAP) and handles plateau detection, NaN values, and irregular time gaps—trendlines remain straight even across weekends and holidays. Supports both dynamic recalculation (updates on each bar) and static mode (freeze the first established line and project forward).
- **ZigZag pattern detection**: Added ZigZag indicator suite (`ZigZagStateIndicator`, `ZigZagPivotHighIndicator`, `ZigZagPivotLowIndicator`, `RecentZigZagSwingHighIndicator`, `RecentZigZagSwingLowIndicator`) that filters out insignificant price movements to reveal underlying trend structure. Unlike window-based swing indicators that require fixed lookback periods, ZigZag adapts dynamically using percentage or absolute price thresholds, making it particularly useful in volatile markets where fixed windows can miss significant moves. Pivot signals fire immediately when reversals are confirmed (no confirmation bar delay), and you can track support/resistance levels dynamically for mean-reversion strategies. Supports both fixed thresholds (e.g., 2% price movement) and dynamic thresholds based on indicators like ATR for volatility-adaptive detection.
- **Fractal-based swing detection**: `RecentFractalSwingHighIndicator` and `RecentFractalSwingLowIndicator` implement Bill Williams' fractal approach, identifying swing points by requiring a specified number of surrounding bars to be strictly higher or lower. Includes plateau-aware logic to handle flat tops and bottoms, making it robust for real-world market data. Good choice if you prefer the classic fractal methodology or need consistent swing detection across different market conditions.
- **Unified swing infrastructure**: All swing indicators share a common foundation (`AbstractRecentSwingIndicator` base class and `RecentSwingIndicator` interface) that provides consistent caching, automatic purging of stale swing points, and a unified API for accessing swing point indexes and values. Makes it straightforward to build custom swing detection algorithms or integrate with trendline tools—implement the interface and you get caching and lifecycle management automatically.
- **Swing point visualization**: `SwingPointMarkerIndicator` highlights confirmed swing points on charts without drawing connecting lines, useful for visualizing where pivots occur. Works with any swing detection algorithm (fractal-based, ZigZag, or custom implementations).
- **Unified data source interface for seamless market data loading**: Finally, a consistent way to load market data regardless of where it comes from! The new `BarSeriesDataSource` interface lets you work with trading domain concepts (ticker, interval, date range) instead of wrestling with file paths, API endpoints, or parsing logic. Want to switch from CSV files to Yahoo Finance API? Just swap the data source implementation—your strategy code stays exactly the same. Implementations include:
- `YahooFinanceBarSeriesDataSource`: Fetch live data from Yahoo Finance (stocks, ETFs, crypto) with optional response caching to speed up development and avoid API rate limits
- `CoinbaseBarSeriesDataSource`: Load historical crypto data from Coinbase's public API with automatic caching
- `CsvFileBarSeriesDataSource`: Load OHLCV data from CSV files with intelligent filename pattern matching (e.g., `AAPL-PT1D-20230102_20231231.csv`)
- `JsonFileBarSeriesDataSource`: Load Coinbase/Binance-style JSON bar data with flexible date filtering
- `BitStampCsvTradesFileBarSeriesDataSource`: Aggregate Bitstamp trade-level CSV data into bars on-the-fly
All data sources support the same domain-driven API: `loadSeries(ticker, interval, start, end)`. No more remembering whether your CSV uses `_bars_from_` or `-PT1D-` in the filename, or which API endpoint returns what format. The interface handles the implementation details so you can focus on building strategies. File-based sources automatically search for matching files, while API-based sources fetch and cache data transparently. See the new `CoinbaseBacktest` and `YahooFinanceBacktest` examples for complete workflows.
- **Elliott Wave analysis package**: Added a comprehensive suite of indicators for Elliott Wave analysis, enabling automated wave counting, pattern recognition, and confidence scoring. The package handles the inherent ambiguity in Elliott Wave analysis by generating multiple plausible wave interpretations with confidence percentages, making it practical for algorithmic trading strategies. All indicators work with swing point data from `RecentSwingIndicator` implementations (fractal or ZigZag), automatically filtering and compressing swings to identify Elliott wave structures. The system treats `DoubleNum` NaN values as invalid using `Num.isNaNOrNull()` and `Num.isValid()`, preventing NaN pivots, ratios, and targets from leaking into analysis.
- **Core swing processing**: `ElliottSwingIndicator` generates Elliott swings from `RecentSwingIndicator` swing points, `ElliottSwingCompressor` filters and compresses swings to identify wave structures, and `ElliottWaveCountIndicator` counts waves in the current pattern. `ElliottSwingMetadata` tracks swing relationships and wave structure.
- **Fibonacci analysis**: `ElliottRatioIndicator` calculates Fibonacci retracement/extension ratios between waves, `ElliottChannelIndicator` projects parallel trend channels for wave validation, and `ElliottConfluenceIndicator` identifies price levels where multiple Fibonacci ratios converge. `ElliottFibonacciValidator` provides continuous proximity scoring methods (`waveTwoProximityScore()`, `waveThreeProximityScore()`, etc.) returning 0.0-1.0 instead of boolean pass/fail.
- **Wave state tracking**: `ElliottPhaseIndicator` tracks the current wave phase (impulse waves 1-5, corrective waves A-B-C), and `ElliottInvalidationIndicator` surfaces invalidations when wave counts break down. `ElliottPhase` enum represents wave phases with degree information.
- **Confidence scoring and scenario generation**: `ElliottConfidence` record captures confidence metrics with weighted factor scores (Fibonacci proximity 35%, time proportions 20%, alternation quality 15%, channel adherence 15%, structure completeness 15%). `ElliottConfidenceScorer` is a stateless utility that calculates confidence from swing data with configurable weights. `ElliottScenario` represents a single wave interpretation with confidence, invalidation level, and Fibonacci-based price targets. `ElliottScenarioSet` is an immutable container holding ranked alternative scenarios with convenience methods (`base()`, `alternatives()`, `consensus()`, `hasStrongConsensus()`, `confidenceSpread()`). `ScenarioType` enum classifies pattern types (IMPULSE, CORRECTIVE_ZIGZAG, CORRECTIVE_FLAT, CORRECTIVE_TRIANGLE, CORRECTIVE_COMPLEX, UNKNOWN). `ElliottScenarioGenerator` generates alternative interpretations by exploring different starting points, pattern types, and degree variations, with automatic pruning of low-confidence scenarios. `ElliottScenarioIndicator` is a CachedIndicator returning `ElliottScenarioSet` for each bar, integrating scenario generation with the indicator framework. `ElliottScenarioComparator` provides utilities for comparing scenarios with methods like `divergenceScore()`, `sharedInvalidation()`, `consensusPhase()`, and `commonTargetRange()`.
- **Price projections and invalidation levels**: `ElliottProjectionIndicator` returns Fibonacci-based price targets from the base case scenario. `ElliottInvalidationLevelIndicator` returns the specific price level that would invalidate the current count, with PRIMARY/CONSERVATIVE/AGGRESSIVE modes.
- **Facade and utilities**: `ElliottWaveFacade` provides a high-level API with scenario-based methods: `scenarios()`, `primaryScenario(int)`, `alternativeScenarios(int)`, `confidenceForPhase(int, ElliottPhase)`, `hasScenarioConsensus(int)`, `scenarioConsensus(int)`, `scenarioSummary(int)`. `ElliottDegree` enum represents wave degrees (SUBMINUETTE through GRAND_SUPERCYCLE) with methods for higher/lower degree navigation. `ElliottChannel` represents parallel trend channels for wave validation.
- **Example and integration**: `ElliottWaveAnalysis` example demonstrates confidence percentages, scenario probability distributions, and alternative scenario display, showing how to integrate Elliott Wave analysis into trading strategies.
- **Chart annotation support**: Added `BarSeriesLabelIndicator` to the ta4j-examples charting package, enabling sparse bar-index labels for chart annotations. The indicator returns label Y-values (typically prices) at labeled indices and `NaN` elsewhere, with integrated support in `TradingChartFactory` for rendering text annotations on charts. Useful for marking significant events, wave labels, or custom annotations on trading charts.
- **Channel overlay helpers**: Added a `PriceChannel` interface (upper/lower/median, shared validity/width/contains helpers, and a boundary enum), a `ChannelBoundaryIndicator` charting helper, and `ChartBuilder.withChannelOverlay(...)` convenience methods (including a channel-indicator overload) for plotting channel boundaries like Elliott channels, Bollinger bands, and Keltner channels. Charting now validates that channel overlays include both upper and lower boundaries, provides TradingView-inspired muted defaults with channel fill shading and a dashed, lower-opacity median line, and exposes a fluent channel styling stage including interior fill controls.
### Changed
- **Elliott Wave scenario probability JSON output**: Scenario probability values are now serialized as decimal ratios (0.0-1.0) rounded to two decimals instead of percentages, while log output remains in percent.
- **CachedIndicator and RecursiveCachedIndicator performance improvements**: Major refactoring to improve indicator caching performance:
- **O(1) eviction**: Replaced ArrayList-based storage with a fixed-size ring buffer (`CachedBuffer`). When `maximumBarCount` is set, evicting old values now takes constant time instead of O(n) per-bar copies.
- **Read-optimized locking**: Migrated from `synchronized` blocks to a non-fair `ReentrantReadWriteLock` with an optimistic (lock-free) fast path for cache hits. Cache misses and invalidation remain write-locked, while read-heavy workloads scale without serializing threads.
- **Reduced allocation churn**: Eliminated `Collections.nCopies()` allocations in the common "advance by 1 bar" case. The ring buffer writes directly to slots without creating intermediate lists.
- **Last-bar mutation caching**: Repeated calls to `getValue(endIndex)` on an unchanged in-progress bar now reuse the cached result. The cache automatically invalidates when the bar is mutated (via `addTrade()` or `addPrice()`) or replaced (via `addBar(..., true)`).
- **Deadlock avoidance**: Fixed lock-order deadlock risk between last-bar caching and the main cache by ensuring last-bar computations and invalidation never run while holding locks needed by cache writes.
- **Iterative prefill under single lock**: `RecursiveCachedIndicator` now uses `CachedBuffer.prefillUntil()` to compute gap values iteratively under one write lock, avoiding repeated lock re-entry and series lookups.
- **Last-bar computation timeout**: Added 5-second timeout for waiting threads to prevent indefinite hangs if the thread owning a last-bar computation dies unexpectedly.
- **ta4j-examples runners and regression coverage**: Added main-source runners (`CachedIndicatorBenchmark`, `RuleNameBenchmark`, `TrendLineAndSwingPointAnalysis`) and a robust regression test (`TrendLineAndSwingPointAnalysisTest`) so full builds stay quiet while still validating trendlines/swing points and enabling chart generation via `main`.
- **BacktestPerformanceTuningHarness**: Expanded the example into a configurable tuning harness that sweeps strategy counts, bar counts, and `maximumBarCount` hints, capturing runtime + GC overhead and identifying the last linear ("sweet spot") configuration. Includes an optional heap sweep that forks a child JVM per `-Xmx` value.
- **Comprehensive README overhaul**: Completely rewrote the README to make Ta4j more approachable for new users while keeping it useful for experienced developers. Added a dedicated "Sourcing market data" section that walks through getting started with Yahoo Finance (no API key required!), explains all available data source implementations, and shows how to create custom data sources. Reorganized content with clearer navigation, better code examples, and practical tips throughout. The Quickstart example now includes proper error handling and demonstrates real-world data loading patterns. New users can go from zero to running their first backtest in minutes, while experienced quants can quickly find the advanced features they need.
### Removed
- Deleted `BuyAndSellSignalsToChartTest.java`, `CashFlowToChartTest.java`, `StrategyAnalysisTest.java`, `TradeCostTest.java`, `IndicatorsToChartTest.java`, `IndicatorsToCsvTest.java` from the ta4j-examples project. Despite designated as "tests", they simply launched the main of the associated class.
- Consolidated `SwingPointAnalysis.java` and `TrendLineAnalysis.java` into `TrendLineAndSwingPointAnalysis.java` to provide a unified analysis example combining both features.
- Removed `TopStrategiesExampleBacktest.java` and `TrendLineDefaultCapsTest.java` as part of example consolidation and test cleanup.
### Fixed
- **Rule naming now lightweight**: `Rule#getName()` is now a simple label (defaults to the class name) and no longer triggers JSON serialization. Composite rules build readable names from child labels without serialization side effects.
- **Explicit rule serialization APIs**: Added `Rule#toJson(BarSeries)`/`Rule#fromJson(BarSeries, String)` so serialization happens only when explicitly requested. Custom names are preserved via `__customName` metadata, independent of `getName()`.
- **Rule name visibility**: Made rule names volatile and added regression coverage so custom names set on one thread are always visible on others, preventing fallback to expensive default-name serialization under concurrency.
- Fixed GitHub Actions `release-scheduler.yml` failure in the `Compute new version` step (bash syntax) and hardened job `if:` conditions.
- Fixed **SuperTrendUpperBandIndicator** NaN contamination by recovering once ATR leaves its unstable window and aligning unstable-bar counts across SuperTrend components so values stabilize after ATR warms up.
- **Consistent NaN handling across all SuperTrend indicators**: `SuperTrendLowerBandIndicator` and `SuperTrendIndicator` now return NaN during the unstable period (when ATR values are not yet reliable), consistent with `SuperTrendUpperBandIndicator` and the project convention that indicators should return NaN during their unstable period. Previously, these indicators returned zero during the unstable period, which was inconsistent with other indicators and could lead to misleading calculations.
- **Test improvements for headless environment compatibility**: Updated strategy and charting tests (`RSI2StrategyTest`, `CCICorrectionStrategyTest`, `GlobalExtremaStrategyTest`, `MovingMomentumStrategyTest`, `ChartWorkflowTest`) to use `SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY` instead of `Assume.assumeFalse()` for headless environment checks. This allows tests to run reliably in both headless and non-headless environments without skipping, improving CI/CD compatibility and test coverage.
+14
View File
@@ -0,0 +1,14 @@
## 0.22.1 (2026-01-15)
### Added
- **Manual GitHub Release workflow trigger**: Added `workflow_dispatch` support with a required tag input so maintainers can backfill or re-run a GitHub Release directly from the Actions UI.
- **GitHub Release dry-run option**: Added a `dryRun` flag for manual GitHub Release triggers to preview notes, validate artifacts, and preflight release token permissions without publishing.
- **Manual actionlint trigger**: Added `workflow_dispatch` support so workflow linting can be run on demand.
### Changed
- **GitHub Release execution path**: Release creation now relies on tag-push triggers (and the manual dispatch option) instead of being invoked directly from `release.yml`, with the workflow checking out the target tag to align notes and artifacts.
- **Release automation tokens**: `release-scheduler.yml` and `release.yml` use `GITHUB_TOKEN`, while `github-release.yml` uses the `GH_TA4J_REPO_TOKEN` classic PAT for GitHub Release creation under org token restrictions.
### Fixed
- **GitHub Release artifacts**: Build now uses the production-release profile so javadoc jars are generated and artifact validation succeeds.
- **GitHub Release asset uploads**: Removed overlapping upload patterns to prevent duplicate asset uploads from failing the release.
+96
View File
@@ -0,0 +1,96 @@
## 0.22.2 (2026-02-15)
### Breaking
- **Trade model split**: `Trade` is now an interface; the previous concrete implementation is `SimulatedTrade` (backtesting/simulation), and live executions are represented by `LiveTrade`.
- **Swing indicator interfaces**: Removed deprecated `RecentSwingHighIndicator` and `RecentSwingLowIndicator`; use `RecentSwingIndicator` and concrete implementations directly.
### Added
- **Charting time axis mode**: Added `TimeAxisMode` with `BAR_INDEX` support to compress non-trading gaps (weekends/holidays) on charts while keeping bar timestamps intact.
- **Concurrent real-time bar series pipeline**: Introduced core support for concurrent, streaming bar ingestion with
a dedicated series (`ConcurrentBarSeries`/builder), realtime bar model (`RealtimeBar`/`BaseRealtimeBar`), and
streaming-bar ingestion helpers to enable candle reconciliation and side/liquidity-aware trade aggregation.
- **Live trading domain model**: Added `ExecutionIntent`, `ExecutionFill`, `LiveTrade`, and `LiveTradingRecord` with
partial-fill tracking, multi-lot bookkeeping, and configurable matching (`FIFO`, `LIFO`, `AVG_COST`, `SPECIFIC_ID`).
- **Strategy trade direction**: Added `Strategy#getStartingType()` (default `BUY`) so strategies can declare long-first
or short-first behavior explicitly.
- **Open position analytics**: Added `PositionLedger`, `OpenPositionCostBasisCriterion`,
`OpenPositionUnrealizedProfitCriterion`, and `TotalFeesCriterion` for live exposure and fee analysis.
- **Sharpe Ratio**: Added `SharpeRatioCriterion` and its related calculation classes
- **Equity-curve controls**: Added `OpenPositionHandling` and `PerformanceIndicator` to standardize mark-to-market vs
realized behavior across performance metrics.
- Added **ThreeInsideUpIndicator** and **ThreeInsideDownIndicator**
- Added **MorningStarIndicator** and **EveningStarIndicator**
- Added **BullishKickerIndicator** and **BearishKickerIndicator**
- Added **PiercingIndicator** and **DarkCloudIndicator**
- **Threshold-based boolean rules**: [#1422](https://github.com/ta4j/ta4j/issues/1422) Added `AndWithThresholdRule`/`OrWithThresholdRule` that also work backwards with a certain threshold.
- Added versions-maven-plugin
- **Elliott Wave analysis toolkit**: Added `ElliottWaveAnalyzer`, `ElliottAnalysisResult`, configurable `PatternSet`,
and the `org.ta4j.core.indicators.elliott.swing` detector/filter package for pluggable, chart-independent analysis.
- **Elliott Wave confidence modeling**: Added profile-driven confidence scoring with factor breakdowns, time
alternation diagnostics, and granular Fibonacci relationship scoring.
- **Elliott Wave trend bias**: Added `ElliottTrendBias` and `ElliottTrendBiasIndicator` for scenario-weighted
bullish/bearish context, plus `ElliottScenarioSet#trendBias()` and `ElliottWaveFacade#trendBias()` helpers.
- **Elliott Wave strategy demos**: Added `ElliottWaveAdaptiveSwingAnalysis`, `ElliottWavePatternProfileDemo`,
`ElliottWaveTrendBacktest`, and `HighRewardElliottWaveBacktest` with `HighRewardElliottWaveStrategy` for
selective impulse entries using confidence, alternation, and risk/reward filters.
- **DonchianChannelFacade**: [#1407](https://github.com/ta4j/ta4j/issues/1407): Added **DonchianChannelFacade** new class providing a facade for DonchianChannel Indicators by using lightweight `NumericIndicators`
- Added constructors accepting custom ATR indicator to **AverageTrueRangeStopGainRule** **AverageTrueRangeStopLossRule** and **AverageTrueRangeTrailingStopLossRule**
- **Sortino Ratio**: Added `SortinoRatioCriterion` for downside deviation-based risk adjustment
### Changed
- **Bar builders null handling**: Bar builders now skip null-valued bars entirely instead of inserting placeholder/null bars, leaving gaps when inputs are missing or invalid.
- **Charting overlays**: Refactored overlay renderer construction and centralized time-axis domain value selection to reduce branching without changing chart output.
- **Charting defaults**: Centralized chart styling defaults (anti-aliasing, background, title paint) for consistency across chart types.
- **Chart builder metadata**: Chart definitions now surface a shared metadata object for domain series, title, and time axis mode; chart plans expose a ChartContext and derive their primary series from it, with ChartWorkflow rendering helpers accepting contexts.
- **TimeBarBuilder**: Enhanced with trade ingestion logic, time alignment validation, and RealtimeBar support.
- **BaseBarSeriesBuilder**: Deprecated `setConstrained` in favor of deriving constrained mode from max-bar-count configuration.
- **Release automation (two-phase, tokens, tooling):** Two-phase workflows (`prepare-release.yml`, `publish-release.yml`) with optional direct push (`RELEASE_DIRECT_PUSH`). Token handling: preflight runs before checkout; `GH_TA4J_REPO_TOKEN` is used for checkout and push only when valid, otherwise `github.token`; fail fast when the token lacks write (warn in dry-run). Release process docs overhauled; pre-push hook runs actionlint on workflow changes (see CONTRIBUTING); agent workflow skips full build for workflow/CHANGELOG/docs-only changes.
- **Release scheduler and discussions:** Gated by `RELEASE_SCHEDULER_ENABLED`. Dispatches through prepare-release only when binary-impacting changes exist (`pom.xml` or `src/main/**`); AI prompt uses summarized binary changes and trimmed changelog with optional `RELEASE_AI_MODEL`. Scheduler and release runs post to GitHub Discussions with run mode and timestamp; binary path list and Maven Central summary in collapsible details; previous dry-run comments for the same tag are removed before posting.
- **Publish-release manual trigger:** For workflow_dispatch, `releaseCommit` is optional and auto-detected from `release/<version>.md` on the default branch so manual publishes require only the release version.
- **Release health workflow:** Scheduled checks for tag reachability, snapshot version drift, stale release PRs, and missing release notes, with summaries posted to Discussions.
- **Factory selection from bars**: Derive the NumFactory from the first available bar price instead of assuming a specific price is always present.
- **CashFlow**: Added a realized-only calculation mode alongside the default mark-to-market cash flow curve.
- **Live/backtest calculation parity**: `CashFlow`, `CumulativePnL`, `Returns`, and drawdown criteria now align on bar
indices, support explicit equity/open-position handling, and correctly account for multiple live lots.
- **Live vs simulated record boundaries**: `BaseTradingRecord` now rejects `LiveTrade`, while charting/analysis paths can
build partial `LiveTradingRecord` instances so recorded live fees are preserved.
- **Live persistence simplification**: Removed `LiveTradingRecordSnapshot`; persistence now uses `LiveTradingRecord`
serialization plus explicit cost-model rehydration.
- **Recorded fee semantics**: Live-trading positions and criteria now use recorded `LiveTrade` fees via
`RecordedTradeCostModel` so PnL reflects actual execution costs.
- **License headers**: Switch Java source file headers to SPDX identifiers.
- **Elliott Wave analysis example**: Scenario probability weighting now applies adaptive confidence contrast so closely scored scenarios separate more clearly.
- **Position duration criterion**: implemented `PositionDurationCriterion` to measure positions duration.
- **Statistics helper**: Consolidated statistics selection into the `Statistics` enum, with Num calculations.
- **Monte Carlo drawdown criterion**: Reused shared statistics helper for simulated drawdown summaries.
- **Dependencies**: update to latest versions
- **Elliott Wave scoring and diagnostics**: Extension ratio scoring now penalizes under/over-extended projections,
chart/JSON outputs include scenario-weighted trend bias, and logs include time alternation diagnostics.
- **CI concurrency**: Cancel in-progress runs for the primary PR/push validation workflows to reduce backlog.
### Fixed
- **Build script**: Ensure `scripts/run-full-build-quiet.sh` creates a temp filter script on macOS by using a trailing-`X` mktemp template and guarding cleanup when the temp list is unset.
- **TimeBarBuilder**: Preserve in-progress bars when trade ingestion skips across multiple time periods.
- **Release workflow notifications**: Fix discussion comment posting in workflows (unescaped template literals).
- **Release workflow hardening**: Improved `prepare-release.yml` token preflight with push-capability checks and an early `git push --dry-run` probe, and fixed `github-script` `core` redeclaration errors in both `prepare-release.yml` and `publish-release.yml`.
- **Release health workflow**: Ensure discussion notifications are posted even when summary generation fails and avoid `github-script` core redeclaration errors.
- **Indicator unstable periods**: Standardized unstable-bar aggregation and warm-up guarding across indicators so pre-warmup bars are handled consistently, including lookback-driven trend/cross indicators.
- **CashFlow**: Prevented NaN values when a position opens and closes on the same bar index.
- **Returns**: Mark-to-market returns now carry forward cost-adjusted prices for consistent holding-cost treatment.
- **BarSeries MaxBarCount**: Fixed sub-series creation to preserve the original series max bars, instead of resetting it to default Integer.MAX_VALUE
- **SimulatedTrade**: Avoid divide-by-zero when calculating net price for zero-amount trades and return a non-null
zero-cost model when deserialized without a transient cost model.
- **Position**: Default transaction/holding cost models after deserialization now return zero-cost models instead of
`null`.
- **Live-trading validation hardening**: `BaseTradingRecord` now rejects empty trade arrays with a clear error, and
`PositionBook` validates null/non-positive live trades before mutating lot state.
- **Strategy starting type propagation**: `BarSeriesManager` default `run(...)` methods now use `Strategy#getStartingType()`, `BaseStrategy` composition (`and`/`or`/`opposite`) preserves non-default starting types, and strategy serialization round-trips retain short-mode (`SELL`) starting type metadata.
- **ExecutionFill index handling**: `LiveTradingRecord.recordExecutionFill(ExecutionFill)` now applies explicit fill indices consistently for both `LiveTrade` and non-`LiveTrade` implementations.
- **Keltner channels**: Rebuild ATR indicators after deserialization and include ATR unstable bars in the reported warmup.
- **Trade interface naming**: Aligned the public `Trade` interface filename with Java conventions to restore compilation.
- **Release scheduler**: Gate release decisions on binary-impacting changes (`pom.xml` or `src/main/**`) so workflow-only updates no longer trigger releases.
- **Release scheduler redaction**: Avoid masking long Java class names in binary-change listings.
- **Release version validation**: Fixed version comparison in `prepare-release.yml` to properly validate that `nextVersion` is greater than `releaseVersion` using semantic version sorting, preventing invalid version sequences.
- **Fixed incorrect @since 0.23** by replacing with 0.22.2
- **Full build script**: Fix macOS temp file creation in `run-full-build-quiet.sh` by using a portable mktemp template.
+49
View File
@@ -0,0 +1,49 @@
## 0.22.3 (2026-03-01)
### Added
- **Bill Williams indicator suite**: Added `FractalHighIndicator`, `FractalLowIndicator`, `AlligatorIndicator` (jaw/teeth/lips defaults), `GatorOscillatorIndicator` (upper/lower histogram branches), and `MarketFacilitationIndexIndicator` with comprehensive regression coverage for confirmation delays, overlapping windows, flat-price/zero-volume edge cases, constructor validation, unstable-bar boundaries, and lower-histogram signed-zero handling.
- **Risk controls APIs**: Added `PositionRiskModel`, `StopLossPositionRiskModel`, and `RMultipleCriterion` for risk-unit (R-multiple) evaluation, plus `StopLossPriceModel`/`StopGainPriceModel` and fixed/trailing/volatility/ATR stop-loss and stop-gain rule variants.
- **Agent guidance tooling and docs**: Reorganized project `AGENTS.md` into scoped, task-local guides and added `scripts/agents_for_target.sh` to resolve effective instructions for any target path.
- **Regression coverage additions**: Added explicit tests for `TimeBarBuilder` gap placement, `NetMomentumIndicator` pivot/decay edge handling, mixed-field serialization routing, named-strategy label/vararg diagnostics, and `VolumeIndicator` rolling-window behavior.
- Added **PiercingLineIndicator** and **DarkCloudCoverIndicator** with configurable body-size, gap, and penetration thresholds for candlestick pattern detection.
- **Trend confirmation oscillators**: Added `VortexIndicator` (+VI, -VI, and oscillator output) and `UltimateOscillatorIndicator` with configurable periods, warm-up guards, and regression tests against published reference values.
- **Volatility-normalized MACD-V toolkit**: Added `VolatilityNormalizedMACDIndicator` with canonical ATR-normalized MACD-V calculation, configurable signal/histogram helpers, and `MACDVMomentumState` classification utilities.
- **MACD-V momentum helper components**: Added `MACDHistogramMode`, `MACDLineValues`, `MACDVMomentumProfile`, `MACDVMomentumStateIndicator`, and `MomentumStateRule` to support configurable histogram polarity, bundled line snapshots, and momentum-state rule composition.
- **MACD-V strategy demo**: Added `MACDVMomentumStateStrategy` to `ta4j-examples`, demonstrating custom signal-line injection and momentum-state filtered entry/exit rules.
### Changed
- **Risk/stop-rule refinements**: Tightened volatility stop-gain coefficient validation, removed redundant risk recomputation in `RMultipleCriterion`, added the missing `AverageTrueRangeTrailingStopLossRule` lookback overload, and expanded shared stop-rule fixtures/tests and Javadocs.
- **VolumeIndicator performance**: Replaced O(barCount) per-index summation with an O(1) rolling partial-sum update, including clearer algorithm/complexity Javadocs.
- **Serialization routing precedence**: `ComponentSerialization` now resolves mixed payloads by descriptor type so strategies prefer `rules` while indicators/rules prefer `components`, while keeping legacy `children`/`baseIndicators` compatibility.
- **NamedStrategy reconstruction diagnostics**: Strategy reconstruction now emits richer, label-aware errors for missing identifiers, malformed labels, and constructor/parameter failures.
- **Build entrypoint + Maven Wrapper compatibility**: `scripts/run-full-build-quiet.sh` now auto-detects and uses
`./mvnw` when present (falling back to `mvn`), so wrapper adoption does not require a second build command.
- **Full build script portability**: `scripts/run-full-build-quiet.sh` no longer requires Python; timeout handling,
quiet-output filtering, heartbeat logging, and test-summary aggregation now run in Bash.
- **Indicator composition reuse**: Added `IndicatorUtils.requireSameSeries(...)` to centralize same-series validation and refactored `VortexIndicator`, `UltimateOscillatorIndicator`, and `TRIndicator` to compose shared true-range/series-validation logic instead of duplicating private helpers.
- **Indicator validation helper consolidation**: Merged `IndicatorSeriesUtils` into `IndicatorUtils`, added shared
`IndicatorUtils.requireIndicator(...)` / `IndicatorUtils.isInvalid(...)`, and refactored Bill Williams, VWAP,
support/resistance, and Wyckoff indicators to reuse centralized validation and NaN-guard logic.
- **Fractal hierarchy consolidation**: Extracted `AbstractFractalConfirmationIndicator` and
`AbstractRecentFractalSwingIndicator`, and added `FractalDetectionHelper.findLatestConfirmedFractalIndex(...)` so
high/low fractal families share confirmation and scanning logic while preserving the existing public APIs.
- **Fractal documentation and regression hardening**: Expanded Javadocs for shared fractal internals and added
`FractalDetectionHelperTest` coverage for latest-pivot scanning, bounds handling, equality allowances, and invalid
input guards.
- **MACD-V signal-line extensibility**: `VolatilityNormalizedMACDIndicator` now supports custom signal-line indicator injection for both signal and histogram generation.
- **MACDVIndicator API robustness and clarity**: Clarified that `MACDVIndicator` is a volume/ATR-weighted MACD variant (not ATR-normalized MACD-V), added default signal/histogram conveniences and constructor overloads, and hardened warm-up/NaN handling with lazy transient sub-indicator rebuild.
- **MACD-V indicator ergonomics**: `MACDVIndicator` and `VolatilityNormalizedMACDIndicator` now expose configuration/sub-indicator getters, line bundle helpers, momentum-state indicator factories, and crossover/momentum rule helpers for strategy composition.
- **MACD-V package organization**: Grouped MACD-V specific helpers and indicators under `org.ta4j.core.indicators.macd` to reduce top-level indicators package clutter, and retained a deprecated compatibility shim for moved `MACDVIndicator` in `org.ta4j.core.indicators` so downstream projects can migrate imports without immediate build breakage.
- **Deprecation visibility for deprecated classes**: Added runtime deprecation warnings (emitted once per classloader) for deprecated compatibility classes in core and legacy JSON helper classes in examples so migration guidance appears directly in logs.
### Fixed
- **Stop-rule behavior and efficiency**: Trailing stop-gain variants now arm only after the configured favorable move, volatility stop-loss variants trigger on exact threshold touches, gain-side helpers are used consistently, and trailing volatility/fixed-amount rules now reuse stabilized max-lookback indicators to reduce hot-path allocations.
- **NetMomentum neutral pivot validation**: Constructor now rejects `NaN`/infinite neutral pivot values to prevent undefined momentum output states.
- **VolumeIndicator constrained-window correctness**: Rolling sums now anchor to the series `beginIndex` so max-bar-count eviction does not double-count or backtrack into pruned history.
- **Publish-release manual dispatch inputs**: `publish-release.yml` now reads `workflow_dispatch` metadata from event inputs so manual reruns correctly receive `releaseVersion`/`releaseCommit`.
- **Prepare-release metadata guard**: Added a Maven Central metadata validation gate in `prepare-release.yml` (including dry-run mode) to fail early when required POM metadata (including developers) is missing.
- **VWAP and Wyckoff market-structure toolkit**: Added rolling/anchored VWAP analytics (deviation, standard deviation, z-score, bands), support/resistance clustering (bounce-count, price-cluster, KDE volume profile), and Wyckoff cycle analysis APIs with a runnable demo.
- **Release workflow dispatch and metadata checks**: `publish-release.yml` now reads `workflow_dispatch` inputs correctly for manual reruns, and `prepare-release.yml` now fails early when required Maven Central POM metadata is missing.
- **README snippet synchronization line endings**: `ReadmeContentManager.updateReadmeSnippets(...)` now preserves the
target README's dominant line separator (LF/CRLF), with regression tests covering both newline modes.
- **Indicator serialization and stability**: Aligned VWAP, price-cluster, and Wyckoff indicators on descriptor ordering, NaN handling, and unstable-bar conventions.
+36
View File
@@ -0,0 +1,36 @@
## 0.22.4 (2026-03-15)
### Added
- **Window-aware criterion evaluation API**: `AnalysisCriterion` can now analyze exactly the slice you care about, including specific bar ranges, date/time ranges, lookback bars, and lookback durations, via `AnalysisWindow`/`AnalysisContext` and `AnalysisCriterion#calculate(series, tradingRecord, window[, context])`, with strict/clamp history policies and configurable open-position handling.
- **Price-structure aggregators**: Added `RangeBarAggregator`, `VolumeBarAggregator`, and `RenkoBarAggregator` for range-, volume-, and Renko-brick derived bar series with externally configurable thresholds and comprehensive fixture-driven regression coverage.
- **One-shot multi-timeframe Elliott Wave analysis**: You can now run `ElliottWaveAnalysisRunner` once and get an `ElliottWaveAnalysisResult` with base + neighboring degree outputs, ranked scenarios, and confidence context in one place.
- **Reusable walk-forward framework with backtest integration**: Added `WalkForwardEngine`, `WalkForwardTuner`, `WalkForwardObjective`, and `StrategyWalkForwardExecutor`, plus `BacktestExecutor` entrypoints so you can run backtest-only, walk-forward-only, or both in one consistent flow.
- **Weighted strategy ranking across execution results**: `TradingStatementExecutionResult` and `BacktestExecutionResult#getTopStrategiesWeighted(...)` now support normalized weighted ranking (for example net profit + drawdown + trade count) with pluggable normalization and deterministic ordering. You can jump in with `WeightedCriterion.of(...)`, `RankingProfile.weighted(...)`, or the direct weighted overloads, and the README plus `SimpleMovingAverageRangeBacktest` now show a concrete “net profit + RoMaD” shortlist flow you can copy directly.
- **Shared scoring/weighting primitives for library extensions**: Added `NamedScoreFunction<I, S>` and `WeightedValue<T>` so indicator, confidence, and walk-forward components can reuse the same scoring and weighted-aggregation contracts.
- **Live Elliott preset demo support**: `ElliottWavePresetDemo` now accepts live tickers (for example `BTC-USD`, `ETH-USD`, `SPY`) so you can run the same EW workflow on non-ossified daily data.
### Changed (Trading Record and Execution Flow)
- **Elliott APIs and demos now follow runner-centric naming and defaults**: The project has moved from legacy analyzer naming to `ElliottWaveAnalysisRunner`, examples are organized under `analysis.elliottwave.{demo,backtest,support}`, and demo defaults now emphasize auto-degree selection with multi-degree context.
- **HighRewardElliottWaveStrategy momentum confirmation now uses MACD-V**: The strategy now uses `VolatilityNormalizedMACDIndicator` and drops redundant exit-rule guarding to keep rule flow cleaner.
- **Release automation now favors safer incremental bumps**: `release-scheduler.yml` and `semver-rules-override.txt` now drive explicit go/no-go decisions with `patch|minor` outputs only, normalize noisy AI bump values (for example ` MAJOR ` or ` minor `), and keep major bumps disabled so automated releases stay predictable for library consumers and maintainers (`#1477`).
- **`@since` policy is now explicit for contributors**: `.github/CONTRIBUTING.md` now clearly requires introducing release versions without `-SNAPSHOT` (for example `@since 0.22.4`), plus a documented 5-minor volatility window so teams can adopt new APIs with clearer risk expectations (`#1477`).
### Fixed
- **Net momentum can now jump straight to later bars without falling over**: `NetMomentumIndicator` now handles large first-lookups and constrained `maximumBarCount` series without blowing the stack, so replay/backtest flows can request a late bar first, warm up on pruned rolling windows, and keep the same momentum values they would get from sequential evaluation.
- **Release workflow drift guardrails**: Release maintainers now get near-immediate drift detection because `release-health.yml` runs on every `master` push and after `Publish Release to Maven Central` completes, and `publish-release.yml` now hard-fails if the pushed release tag does not resolve to the expected `releaseCommit` or is not reachable from `origin/<default_branch>`.
- **Rolling variance now matches sample-statistics expectations by default**: `VarianceIndicator` now computes sample variance out of the box (`n-1` divisor), so spreadsheet-style checks like issue `#1152` line up directly. You can now choose behavior explicitly with `SampleType`/factory helpers across `VarianceIndicator`, `StandardDeviationIndicator`, `StandardErrorIndicator`, `SigmaIndicator`, and `CorrelationCoefficientIndicator` (for example: `VarianceIndicator.ofSample(...)`, `VarianceIndicator.ofPopulation(...)`, `StandardDeviationIndicator.ofSample(...)`, or `CorrelationCoefficientIndicator.ofPopulation(...)`).
- **Enter-and-hold wrappers now keep return format metadata intact**: `EnterAndHoldCriterion` now forwards `getReturnRepresentation()` from its wrapped criterion, so downstream consumers can reliably detect whether outputs are decimal, percentage, multiplicative, or log without special casing wrapper criteria.
- **Elliott analysis hardening**: Enforced bounded `ElliottDegree.RecommendedHistory` ranges, hardened ossified resource loading for classpath edge cases, and simplified redundant trend-bias null guarding in EW analysis reporting.
- **Walk-forward fold metadata stability**: Fixed fold-value reporting so criterion maps and fold views remain stable and deterministic when consumers rely on fold order for downstream comparisons.
### Breaking
- **Trade and record handling now has one obvious happy path**: Build fill-aware trades with `Trade.fromFill(...)` or `Trade.fromFills(...)`, then pass them to `TradingRecord#operate(...)`. The older live-only wrappers (`ExecutionFill`, `LiveTrade`, `LiveTradingRecord`, and `PositionLedger`) are still available in 0.22.x as deprecated migration shims, but they are no longer the API you should reach for first.
- **Open-position APIs now use `Position` end to end**: `TradingRecord#getOpenPositions()` returns open `Position` snapshots, `getCurrentPosition()` remains the canonical net-open view, and `getNetOpenPosition()` is now just a compatibility alias. The separate `OpenPosition` type is gone.
- **Lot bookkeeping is finally internal again**: `PositionBook` and `PositionLot` now stay inside `BaseTradingRecord`, while FIFO, LIFO, average-cost, and specific-id matching keep the same external behavior.
### Changed (Backtest Execution Models and Custom Records)
- **Partial-fill recording is smoother in live-style flows**: You can now stream one fill at a time with `TradingRecord.operate(fill)` when fills arrive incrementally, or keep using `Trade.fromFills(...)` plus `operate(...)` when you already have the whole batch for one logical order. `TradeFillRecordingExample` in `ta4j-examples` now walks through both styles and also shows how `FIFO`, `LIFO`, `AVG_COST`, and `SPECIFIC_ID` change partial-exit matching.
- **Bring your own trading record in backtests**: `BarSeriesManager` can now run directly against a caller-provided `TradingRecord` (`run(strategy, tradingRecord[, amount, start, end])`) and can also be configured with a default `TradingRecordFactory`, so you can keep standard `BaseTradingRecord` runs or wire custom record implementations without changing existing `run(...)` calls.
- **`BacktestExecutor` now picks up the same backtest wiring without extra boilerplate**: You can construct it directly with a `TradeExecutionModel` for the common slippage/stop-limit case, or hand it a preconfigured `BarSeriesManager` so custom `TradingRecordFactory` behavior flows through normal backtest, top-K, and walk-forward execution.
- **Execution-model examples are easier to copy straight into your own backtests**: `TradingRecordParityBacktest` in `ta4j-examples` now walks through next-open, current-close, and slippage fills side by side, then verifies the same behavior with provided and factory-configured `BaseTradingRecord` runs.
- **Stop-limit/live parity hardening**: `StopLimitExecutionModel` now expires stale pending orders before accepting new signals (so old orders cannot block fresh ones), commits partial expiry fills on unified `BaseTradingRecord` exit flows for better real-world fill progression, and keeps rejection metadata for the unfilled remainder.
+12
View File
@@ -0,0 +1,12 @@
## 0.22.5 (2026-03-29)
### Added
- **Calmar and Omega ratios**: Added `CalmarRatioCriterion` and `OmegaRatioCriterion` for drawdown-adjusted CAGR and threshold-based return-distribution asymmetry analysis.
- **Release PR freeze is now visible and enforced**: while any PR labeled `release` is open against `master`, non-release PR merges are now blocked by `.github/workflows/release-freeze.yml`; other open PRs automatically get a freeze notice with direct links to active release PRs, and that notice is removed once release PRs close or merge. This prevents “whoops, this should have waited for release” merge windows during release prep (`#1481`).
### Changed
- **Release workflows are now safer for release orchestration**: `cancel-in-progress` is disabled in `prepare-release.yml`, `publish-release.yml`, `github-release.yml`, `release-health.yml`, and `release-scheduler.yml` so release runs can continue through manual triggers, schedules, and chained events. Fast-feedback workflows keep concurrency cancellation (`actionlint.yml`, `check.yml`, `test.yml`, `validate.yml`, `snapshot.yml`) enabled for PR responsiveness.
- **Release tag baselines now follow reachable ancestry instead of first-parent only**: `release-health.yml` and `release-scheduler.yml` now resolve tags through a shared `scripts/resolve-release-tags.sh` helper, so the scheduler diffs and version floors use the newest release tag actually reachable from `master`, while first-parent tag lag remains an explicit diagnostic only.
### Fixed
- **Windowed maximum drawdown now stays inside the requested analysis range**: `MaximumDrawdownCriterion#calculate(series, tradingRecord, window[, context])` now bounds its cash-flow work to the requested window instead of propagating across the full trailing series, so long cached histories no longer make small windowed drawdown calculations slower as the overall series grows (`#1485`).
+4
View File
@@ -0,0 +1,4 @@
## 0.22.6 (2026-04-01)
### Fixed
- **GitHub Release reruns are much less fragile on cold runners**: `github-release.yml` now caches Maven dependencies and retries release artifact packaging with backoff, so a single Maven Central connection reset does not derail tag backfills or release reruns like `0.22.5`.
+27
View File
@@ -0,0 +1,27 @@
# AGENTS instructions for `ta4j/scripts`
Use this guide when editing helper scripts or when orchestrating worktree/process commands from this directory.
## Build script contract
- `run-full-build-quiet.sh` must enforce a default 3-minute timeout (`QUIET_BUILD_TIMEOUT_SECONDS` can override).
- Preserve quiet-build behavior: full log under `.agents/logs/` and concise aggregated stdout.
- Use `mktemp` templates ending in `XXXXXX` for macOS portability.
## Scoped AGENTS discovery helper
- `agents_for_target.sh` should work from repo root or workspace root.
- Accept either a class/file name or a path, and print path-scoped `AGENTS.md` files in precedence order for matching targets from the current repo/workspace root.
- Keep the helper limited to path-scoped `AGENTS.md` discovery; agents must still load system, developer, user, and workflow-specific instructions separately.
## Worktree/process operations
- For non-trivial work, prefer dedicated worktrees under `.agents/worktrees/`.
- Keep a living PRD/checklist during implementation so work can resume with minimal rediscovery.
- Use branch prefixes `feature/`, `bugfix/`, or `refactor/`.
## Release helper scripts
- Keep release workflow business logic in reusable scripts under `scripts/release/` instead of copying large logic blocks into workflow YAML.
- When adding release helper behavior, add fixture coverage under `scripts/tests/` and keep GitHub Actions outputs/artifacts sanitized.
- Release helper scripts should emit concise `audit:` lines for workflow logs and write structured files that can be uploaded with `actions/upload-artifact`.
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env sh
set -eu
target_glob="${1:-}"
if [ -z "$target_glob" ]; then
echo "Usage: $(basename "$0") <target_filename_or_glob>" >&2
exit 2
fi
workspace_root() {
# Prefer git root if available
if command -v git >/dev/null 2>&1; then
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$root" ]; then
echo "$root"
return
fi
fi
# Otherwise, walk up looking for common project sentinels
d="$PWD"
while [ "$d" != "/" ]; do
if [ -d "$d/.git" ] || \
[ -f "$d/pyproject.toml" ] || \
[ -f "$d/package.json" ] || \
[ -f "$d/pom.xml" ] || \
[ -f "$d/build.gradle" ] || \
[ -f "$d/go.mod" ] || \
[ -f "$d/Cargo.toml" ]; then
echo "$d"
return
fi
d="$(dirname "$d")"
done
# Fallback: current directory
echo "$PWD"
}
root="$(workspace_root)"
# If rg isn't available, fail fast with a clear message
if ! command -v rg >/dev/null 2>&1; then
echo "Error: ripgrep (rg) not found in PATH." >&2
exit 127
fi
# Find matches under root, including hidden and ignored files,
# so AGENTS candidates are discovered even in filtered directories.
rg --files --no-ignore --hidden -g "$target_glob" "$root" 2>/dev/null \
| while IFS= read -r file; do
[ -z "$file" ] && continue
dir="$(dirname "$file")"
# Walk upward from the target's directory to the workspace root
while :; do
agents="$dir/AGENTS.md"
if [ -f "$agents" ]; then
echo "$agents"
fi
[ "$dir" = "$root" ] && break
parent="$(dirname "$dir")"
[ "$parent" = "$dir" ] && break
dir="$parent"
done
done \
| awk '!seen[$0]++'
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
scripts/benchmark-backtest-throughput.sh [base-ref] [candidate-ref] [output-dir] [-- harness args...]
Defaults:
base-ref: HEAD^
candidate-ref: HEAD
output-dir: .agents/benchmarks/backtest-throughput/<timestamp>
The script runs BacktestPerformanceTuningHarness throughput-control mode in
temporary worktrees for both refs, then compares matrix_performance.json
cells/min and hypotheses/min.
Both refs must already contain throughput-control support. The intended
candidate-flow comparison is HEAD^ vs HEAD after adding the harness, telemetry,
and optimization commits.
USAGE
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
repo_root="$(git rev-parse --show-toplevel)"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
base_ref="HEAD^"
candidate_ref="HEAD"
output_dir="$repo_root/.agents/benchmarks/backtest-throughput/$timestamp"
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
base_ref="$1"
shift
fi
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
candidate_ref="$1"
shift
fi
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
output_dir="$1"
shift
fi
if [[ "$output_dir" != /* ]]; then
output_dir="$repo_root/$output_dir"
fi
if [[ "${1:-}" == "--" ]]; then
shift
fi
default_harness_args=(
--throughputControl
--matrixStrategyCounts "250,500,1000"
--matrixBarCounts "500,1000"
--matrixMaxBarCountHints 0
--executionMode topK
--topK 10
--parallelism 1
)
if [[ "$#" -gt 0 ]]; then
harness_args=(--throughputControl "$@")
else
harness_args=("${default_harness_args[@]}")
fi
worktree_parent="$output_dir/worktrees"
mkdir -p "$output_dir" "$worktree_parent"
worktree_root="$(mktemp -d "$worktree_parent/run-XXXXXX")"
base_worktree="$worktree_root/base"
candidate_worktree="$worktree_root/candidate"
base_output="$output_dir/base"
candidate_output="$output_dir/candidate"
comparison_json="$output_dir/comparison.json"
comparison_md="$output_dir/summary.md"
cleanup() {
git -C "$repo_root" worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
git -C "$repo_root" worktree remove --force "$candidate_worktree" >/dev/null 2>&1 || true
rm -rf "$worktree_root"
}
trap cleanup EXIT
git -C "$repo_root" worktree add --detach "$base_worktree" "$base_ref" >/dev/null
git -C "$repo_root" worktree add --detach "$candidate_worktree" "$candidate_ref" >/dev/null
quote_exec_arg() {
local value="$1"
if [[ "$value" =~ ^[A-Za-z0-9_./:=,+%-]+$ ]]; then
printf '%s' "$value"
return
fi
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '"%s"' "$value"
}
run_ref() {
local worktree="$1"
local run_output="$2"
mkdir -p "$run_output"
local exec_args=""
local arg
for arg in "${harness_args[@]}" --throughputOutputDir "$run_output"; do
local quoted_arg
quoted_arg="$(quote_exec_arg "$arg")"
if [[ -n "$exec_args" ]]; then
exec_args+=" "
fi
exec_args+="$quoted_arg"
done
(
cd "$worktree"
mvn -q -pl ta4j-examples -am compile
mvn -q -pl ta4j-examples exec:java \
-Dexec.mainClass=ta4jexamples.backtesting.BacktestPerformanceTuningHarness \
-Dexec.args="$exec_args"
)
}
run_ref "$base_worktree" "$base_output"
run_ref "$candidate_worktree" "$candidate_output"
python3 - "$base_output/matrix_performance.json" "$candidate_output/matrix_performance.json" \
"$comparison_json" "$comparison_md" "$base_ref" "$candidate_ref" <<'PY'
import json
import sys
from pathlib import Path
base_path = Path(sys.argv[1])
candidate_path = Path(sys.argv[2])
comparison_path = Path(sys.argv[3])
summary_path = Path(sys.argv[4])
base_ref = sys.argv[5]
candidate_ref = sys.argv[6]
base = json.loads(base_path.read_text())
candidate = json.loads(candidate_path.read_text())
required_metrics = (
"cellsPerMinute",
"hypothesesPerMinute",
"totalWallTimeMs",
"strategyBuildWallTimeMs",
"backtestRuntimeMs",
)
required_meta = (
"specFingerprint",
"dataset",
"cellCount",
"hypothesisCount",
"parallelism",
"resolvedParallelism",
"executionMode",
"topK",
"progress",
"gcBetweenRuns",
)
for key in (*required_metrics, *required_meta):
if key not in base or key not in candidate:
raise SystemExit(f"Missing required key '{key}' in matrix_performance.json")
if "host" not in base or "host" not in candidate:
raise SystemExit("Missing required key 'host' in matrix_performance.json")
if "hostId" not in base["host"] or "hostId" not in candidate["host"]:
raise SystemExit("Missing required key 'host.hostId' in matrix_performance.json")
def pct(before, after):
if before == 0:
return None
return (after - before) * 100.0 / before
metrics = {}
for name in required_metrics:
before = float(base[name])
after = float(candidate[name])
metrics[name] = {
"base": before,
"candidate": after,
"delta": after - before,
"deltaPct": pct(before, after),
}
comparison = {
"baseRef": base_ref,
"candidateRef": candidate_ref,
"basePath": str(base_path),
"candidatePath": str(candidate_path),
"specFingerprintMatch": base.get("specFingerprint") == candidate.get("specFingerprint"),
"datasetMatch": base.get("dataset") == candidate.get("dataset"),
"cellCountMatch": base.get("cellCount") == candidate.get("cellCount"),
"hypothesisCountMatch": base.get("hypothesisCount") == candidate.get("hypothesisCount"),
"hostIdMatch": base["host"]["hostId"] == candidate["host"]["hostId"],
"metrics": metrics,
}
comparison_path.write_text(json.dumps(comparison, indent=2, sort_keys=True) + "\n")
if not all((
comparison["specFingerprintMatch"],
comparison["datasetMatch"],
comparison["cellCountMatch"],
comparison["hypothesisCountMatch"],
comparison["hostIdMatch"],
)):
raise SystemExit("Invalid comparison: host/dataset/spec/hypothesis/cell counts differ between refs.")
def fmt(value):
return "n/a" if value is None else f"{value:.2f}"
lines = [
f"# Backtest Throughput Comparison",
"",
f"- Base: `{base_ref}`",
f"- Candidate: `{candidate_ref}`",
f"- Dataset match: `{comparison['datasetMatch']}`",
f"- Spec fingerprint match: `{comparison['specFingerprintMatch']}`",
f"- Cell count match: `{comparison['cellCountMatch']}`",
f"- Hypothesis count match: `{comparison['hypothesisCountMatch']}`",
f"- Host ID match: `{comparison['hostIdMatch']}`",
"",
"| Metric | Base | Candidate | Delta % |",
"| --- | ---: | ---: | ---: |",
]
for name in ("cellsPerMinute", "hypothesesPerMinute", "totalWallTimeMs", "strategyBuildWallTimeMs", "backtestRuntimeMs"):
metric = metrics[name]
lines.append(f"| {name} | {metric['base']:.2f} | {metric['candidate']:.2f} | {fmt(metric['deltaPct'])} |")
summary_path.write_text("\n".join(lines) + "\n")
print(f"comparison: {comparison_path}")
print(f"summary: {summary_path}")
PY
cat "$comparison_md"
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT_DIR
python3 <<'PY'
import os
import re
import sys
from pathlib import Path
root = Path(os.environ["ROOT_DIR"])
wiki_root = root.parent / "ta4j-wiki"
wiki_available = wiki_root.exists()
docs_for_link_checks = [root / "README.md", root / "ta4j-examples" / "README.md"]
if wiki_available:
docs_for_link_checks.extend(sorted(wiki_root.glob("*.md")))
docs_for_command_checks = [
root / "README.md",
root / "ta4j-examples" / "README.md",
]
if wiki_available:
docs_for_command_checks.extend(
[
wiki_root / "Usage-examples.md",
wiki_root / "Examples-Expected-Outputs.md",
wiki_root / "Performance-Characterization.md",
]
)
todo_forbidden_docs = [
root / "README.md",
root / "ta4j-examples" / "README.md",
]
if wiki_available:
todo_forbidden_docs.extend(
[
wiki_root / "Home.md",
wiki_root / "Getting-started.md",
wiki_root / "Backtesting.md",
wiki_root / "Live-trading.md",
wiki_root / "Canonical-User-Journey.md",
wiki_root / "Execution-Decision-Matrix.md",
wiki_root / "Migration-and-Version-Compatibility.md",
wiki_root / "Examples-Expected-Outputs.md",
wiki_root / "Performance-Characterization.md",
]
)
required_canonical_docs = [
root / "README.md",
root / "ta4j-examples" / "README.md",
root / "ta4j-core" / "README.md",
]
if wiki_available:
required_canonical_docs.extend(
[
wiki_root / "Home.md",
wiki_root / "Getting-started.md",
wiki_root / "Canonical-User-Journey.md",
wiki_root / "Backtesting-Realism-Checklist.md",
wiki_root / "Live-Trading-Runbook.md",
wiki_root / "Troubleshooting-Hub.md",
wiki_root / "Execution-Decision-Matrix.md",
wiki_root / "Migration-and-Version-Compatibility.md",
wiki_root / "Examples-Expected-Outputs.md",
wiki_root / "Performance-Characterization.md",
]
)
link_pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
main_class_pattern = re.compile(r"-Dexec\.mainClass=([A-Za-z0-9_.]+)")
errors = []
def normalize_target(raw_target: str) -> str:
target = raw_target.strip()
if " " in target and target.startswith(("http://", "https://")):
target = target.split(" ", 1)[0]
if "#" in target:
target = target.split("#", 1)[0]
if "?" in target:
target = target.split("?", 1)[0]
return target.strip()
def check_markdown_links(path: Path) -> None:
if not path.exists():
errors.append(f"Missing documentation file for link check: {path}")
return
content = path.read_text(encoding="utf-8")
for match in link_pattern.finditer(content):
raw_target = match.group(1).strip()
target = normalize_target(raw_target)
if not target:
continue
if target.startswith(("http://", "https://", "mailto:")):
continue
if target.startswith("#"):
continue
candidate = (path.parent / target).resolve()
if not candidate.exists():
errors.append(f"{path}: broken relative link target '{raw_target}'")
def check_exec_main_classes(path: Path) -> None:
if not path.exists():
errors.append(f"Missing documentation file for command check: {path}")
return
content = path.read_text(encoding="utf-8")
for main_class in main_class_pattern.findall(content):
class_file = (
root
/ "ta4j-examples"
/ "src"
/ "main"
/ "java"
/ Path(*main_class.split(".")).with_suffix(".java")
)
if not class_file.exists():
errors.append(
f"{path}: -Dexec.mainClass points to missing class '{main_class}' ({class_file})"
)
def check_for_todo_markers(path: Path) -> None:
if not path.exists():
errors.append(f"Missing documentation file for TODO check: {path}")
return
content = path.read_text(encoding="utf-8")
if "TODO" in content:
errors.append(f"{path}: contains TODO marker in user-facing docs")
for doc_path in docs_for_link_checks:
check_markdown_links(doc_path)
for doc_path in docs_for_command_checks:
check_exec_main_classes(doc_path)
for doc_path in todo_forbidden_docs:
check_for_todo_markers(doc_path)
for doc_path in required_canonical_docs:
if not doc_path.exists():
errors.append(f"Missing canonical documentation artifact: {doc_path}")
if errors:
print("docs-integrity:fail")
for error in errors:
print(f"- {error}")
sys.exit(1)
if not wiki_available:
print("docs-integrity:note ta4j-wiki checkout not found; skipping wiki checks")
print("docs-integrity:pass")
PY
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# prepare-release.sh
#
# Responsibilities:
# - Validate tools
# - Update CHANGELOG.md (move "Unreleased" -> new release section)
# - Update README.md version references
# - Generate standalone release notes file
#
# What it does NOT do:
# - No Maven calls
# - No version bumping
# - No tagging
# - No Git commits
#
# Usage:
# scripts/prepare-release.sh <release-version>
#
# Output:
# release_version=<version>
# release_notes_file=<file>
#
# =============================================================================
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <release-version>" >&2
exit 1
fi
RELEASE_VERSION="$1"
RELEASE_NOTES_FILE="release/${RELEASE_VERSION}.md"
# -----------------------------------------------------------------------------
# Tools check
# -----------------------------------------------------------------------------
for tool in git perl python3; do
command -v "$tool" >/dev/null 2>&1 || {
echo "Error: required tool '$tool' not found" >&2
exit 1
}
done
# -----------------------------------------------------------------------------
# Update CHANGELOG.md
# -----------------------------------------------------------------------------
update_changelog() {
local version="$1"
local outfile="$2"
python3 - "$version" "$outfile" <<'PY'
import datetime, sys, re
from pathlib import Path
version = sys.argv[1]
outfile = Path(sys.argv[2])
changelog = Path("CHANGELOG.md")
text = changelog.read_text()
today = datetime.date.today().strftime("%Y-%m-%d")
release_header = f"## {version} ({today})"
# Find the "Unreleased" section
m = list(re.finditer(r"^##\s*\[?Unreleased\]?", text, re.IGNORECASE | re.MULTILINE))
if not m:
# No Unreleased section → create an empty one
sections = f"## Unreleased\n\n- _No changes yet._\n\n{release_header}\n\n_No changes recorded._\n"
changelog.write_text(sections)
outfile.parent.mkdir(exist_ok=True, parents=True)
outfile.write_text(f"{release_header}\n\n_No changes recorded._\n")
sys.exit(0)
start = m[0].end()
# Find next section after Unreleased
next_sec = list(re.finditer(r"^##\s+", text[start:], re.MULTILINE))
end = start + (next_sec[0].start() if next_sec else len(text[start:]))
unreleased_block = text[start:end].strip()
if not unreleased_block:
unreleased_block = "_No notable changes recorded._"
# New changelog structure:
# 1. Unreleased (reset)
# 2. New release version section
# 3. Everything after the old Unreleased section
new_unreleased = "## Unreleased\n\n- _No changes yet._\n\n"
new_release = f"{release_header}\n\n{unreleased_block}\n\n"
new_text = new_unreleased + new_release + text[end:].lstrip()
changelog.write_text(new_text)
# Write standalone release notes
outfile.parent.mkdir(exist_ok=True, parents=True)
outfile.write_text(f"{release_header}\n\n{unreleased_block}\n")
PY
}
# -----------------------------------------------------------------------------
# README Sentinel Validation
# -----------------------------------------------------------------------------
require_readme_sentinels() {
if [[ ! -f README.md ]]; then
echo "Error: README.md not found" >&2
exit 1
fi
local missing=0
local markers=(
"TA4J_VERSION_BLOCK:core:stable:begin"
"TA4J_VERSION_BLOCK:core:stable:end"
"TA4J_VERSION_BLOCK:core:snapshot:begin"
"TA4J_VERSION_BLOCK:core:snapshot:end"
"TA4J_VERSION_BLOCK:examples:stable:begin"
"TA4J_VERSION_BLOCK:examples:stable:end"
"TA4J_VERSION_BLOCK:examples:snapshot:begin"
"TA4J_VERSION_BLOCK:examples:snapshot:end"
)
for marker in "${markers[@]}"; do
if ! grep -Eq "<!--[[:space:]]*${marker}[[:space:]]*-->" README.md; then
echo "Error: missing README sentinel <!-- ${marker} -->" >&2
missing=1
fi
done
if [[ $missing -ne 0 ]]; then
exit 1
fi
}
# -----------------------------------------------------------------------------
# Snapshot Version Calculator
# -----------------------------------------------------------------------------
compute_snapshot_version() {
local version="$1"
python3 - "$version" <<'PY'
import re
import sys
version = sys.argv[1]
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version)
if not m:
print(f"Error: invalid release version '{version}' (expected major.minor.patch)", file=sys.stderr)
sys.exit(1)
major, minor, patch = map(int, m.groups())
print(f"{major}.{minor}.{patch + 1}-SNAPSHOT")
PY
}
# -----------------------------------------------------------------------------
# README / Version Reference Updater
# -----------------------------------------------------------------------------
update_readme() {
local version="$1"
local snapshot_version="$2"
export VERSION="$version"
export SNAPSHOT_VERSION="$snapshot_version"
perl -0777 -i -pe '
sub bump_version_tags {
my ($block, $v) = @_;
$block =~ s{(<version>)[^<]+(</version>)}{$1$v$2}g;
return $block;
}
# core stable
s{
(<!--\s*TA4J_VERSION_BLOCK:core:stable:begin\s*-->)
(.*?)
(<!--\s*TA4J_VERSION_BLOCK:core:stable:end\s*-->)
}{$1 . bump_version_tags($2, $ENV{VERSION}) . $3}gsex;
# core snapshot
s{
(<!--\s*TA4J_VERSION_BLOCK:core:snapshot:begin\s*-->)
(.*?)
(<!--\s*TA4J_VERSION_BLOCK:core:snapshot:end\s*-->)
}{$1 . bump_version_tags($2, $ENV{SNAPSHOT_VERSION}) . $3}gsex;
# examples stable
s{
(<!--\s*TA4J_VERSION_BLOCK:examples:stable:begin\s*-->)
(.*?)
(<!--\s*TA4J_VERSION_BLOCK:examples:stable:end\s*-->)
}{$1 . bump_version_tags($2, $ENV{VERSION}) . $3}gsex;
# examples snapshot
s{
(<!--\s*TA4J_VERSION_BLOCK:examples:snapshot:begin\s*-->)
(.*?)
(<!--\s*TA4J_VERSION_BLOCK:examples:snapshot:end\s*-->)
}{$1 . bump_version_tags($2, $ENV{SNAPSHOT_VERSION}) . $3}gsex;
' README.md
perl -0pi -e "s|Current version: \`[0-9]+\\.[0-9]+(\\.[0-9]+)?\`|Current version: \`${version}\`|g" README.md || true
if ! grep -Fq "<version>${VERSION}</version>" README.md; then
echo "Error: expected release version ${VERSION} to appear in README.md" >&2
exit 1
fi
if ! grep -Fq "<version>${SNAPSHOT_VERSION}</version>" README.md; then
echo "Error: expected snapshot version ${SNAPSHOT_VERSION} to appear in README.md" >&2
exit 1
fi
}
echo "Preparing release: $RELEASE_VERSION"
require_readme_sentinels
SNAPSHOT_VERSION="$(compute_snapshot_version "$RELEASE_VERSION")"
update_changelog "$RELEASE_VERSION" "$RELEASE_NOTES_FILE"
update_readme "$RELEASE_VERSION" "$SNAPSHOT_VERSION"
echo
echo "release_version=${RELEASE_VERSION}"
echo "release_notes_file=${RELEASE_NOTES_FILE}"
@@ -0,0 +1,15 @@
# Known Javadoc warnings accepted during release publishing.
# Keep this file empty unless a warning is intentionally carried as release debt.
ta4j-core/src/main/java/org/ta4j/core/indicators/ChopIndicator.java: warning: unknown tag. Unregistered custom tag?
ta4j-core/src/main/java/org/ta4j/core/analysis/CumulativePnL.java: warning: reference not found: org.ta4j.core.PerformanceIndicator
ta4j-core/src/main/java/org/ta4j/core/num/DoubleNum.java: warning: unknown tag. Unregistered custom tag?
ta4j-core/src/main/java/org/ta4j/core/indicators/elliott/ElliottScenarioSet.java: warning: invalid input: '<'
ta4j-core/src/main/java/org/ta4j/core/indicators/FisherIndicator.java: warning: unknown tag. Unregistered custom tag?
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: NetReturnCriterion
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: ReturnRepresentationPolicy
ta4j-core/src/main/java/org/ta4j/core/criteria/ReturnRepresentation.java: warning: reference not found: Returns
ta4j-examples/src/main/java/ta4jexamples/backtesting/CoinbaseBacktest.java: warning: invalid input: '&'
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource#loadSeries(String)
@@ -0,0 +1,941 @@
#!/usr/bin/env python3
"""Utilities shared by ta4j release GitHub Actions workflows."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import pathlib
import re
import subprocess
import sys
import textwrap
import urllib.request
from urllib.parse import urlparse
import xml.etree.ElementTree as ET
from typing import Any
SECRET_PATTERNS = (
(re.compile(r"https?://\S+"), "[REDACTED_URL]"),
(re.compile(r"gh[oprsu]_[A-Za-z0-9_]{20,}"), "[REDACTED_TOKEN]"),
(
re.compile(r"(?<![A-Za-z0-9_])(?=[A-Za-z0-9+/=_-]{32,})(?=.*[0-9])[A-Za-z0-9+/=_-]{32,}"),
"[REDACTED_SECRET]",
),
)
EXPECTED_RELEASE_ARTIFACTS = (
"ta4j-core/target/ta4j-core-{version}.jar",
"ta4j-core/target/ta4j-core-{version}-sources.jar",
"ta4j-core/target/ta4j-core-{version}-javadoc.jar",
"ta4j-core/target/ta4j-core-{version}-tests.jar",
"ta4j-examples/target/ta4j-examples-{version}.jar",
"ta4j-examples/target/ta4j-examples-{version}-sources.jar",
"ta4j-examples/target/ta4j-examples-{version}-javadoc.jar",
)
JAVADOC_PATH_PATTERN = re.compile(r"^.*?(ta4j-(?:core|examples)/src/.+)$")
SNAPSHOT_METADATA_URL = "https://central.sonatype.com/repository/maven-snapshots/org/ta4j/ta4j-parent/maven-metadata.xml"
SNAPSHOT_WORKFLOW_NAME = "Publish Snapshot to Maven Central"
def redact(value: str) -> str:
redacted = value
for pattern, replacement in SECRET_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted
def redact_log_path(value: str) -> str:
redacted = value
for pattern, replacement in SECRET_PATTERNS[:2]:
redacted = pattern.sub(replacement, redacted)
return redacted
def run_git(args: list[str], *, check: bool = True) -> str:
completed = subprocess.run(
["git", *args],
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return completed.stdout
def write_json(path: pathlib.Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_text(path: pathlib.Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
def append_output(name: str, value: str, output_path: str | None = None) -> None:
target = output_path or os.environ.get("GITHUB_OUTPUT")
if not target:
return
with open(target, "a", encoding="utf-8") as handle:
if "\n" in value:
delimiter = f"EOF_{name}_{os.getpid()}"
handle.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
else:
handle.write(f"{name}={value}\n")
def load_catalog(args: argparse.Namespace) -> list[dict[str, Any]]:
if args.catalog_file:
with open(args.catalog_file, "r", encoding="utf-8") as handle:
data = json.load(handle)
else:
request = urllib.request.Request(
args.catalog_url,
headers={
"Accept": "application/json",
"User-Agent": "ta4j-release-automation",
},
)
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
data = json.load(response)
if not isinstance(data, list):
raise ValueError("model catalog response must be a JSON array")
return data
def command_catalog_preflight(args: argparse.Namespace) -> int:
model_id = args.model.strip()
if not model_id:
raise ValueError("--model is required")
catalog = load_catalog(args)
selected = next((entry for entry in catalog if entry.get("id") == model_id), None)
if selected is None:
available = ", ".join(sorted(str(entry.get("id")) for entry in catalog if entry.get("id")))
print(f"::error::Configured RELEASE_AI_MODEL '{model_id}' was not found in the GitHub Models catalog.", file=sys.stderr)
print(f"Available models: {available}", file=sys.stderr)
return 1
limits = selected.get("limits") if isinstance(selected.get("limits"), dict) else {}
max_input_tokens = str(limits.get("max_input_tokens", ""))
max_output_tokens = str(limits.get("max_output_tokens", ""))
result = {
"id": selected.get("id", model_id),
"name": selected.get("name", ""),
"publisher": selected.get("publisher", ""),
"summary": selected.get("summary", ""),
"rate_limit_tier": selected.get("rate_limit_tier", ""),
"max_input_tokens": max_input_tokens,
"max_output_tokens": max_output_tokens,
"html_url": selected.get("html_url", ""),
}
write_json(args.output, result)
append_output("model_id", str(result["id"]))
append_output("model_name", str(result["name"]))
append_output("model_summary", str(result["summary"]))
append_output("model_rate_limit_tier", str(result["rate_limit_tier"]))
append_output("model_max_input_tokens", max_input_tokens)
append_output("model_max_output_tokens", max_output_tokens)
append_output("model_html_url", str(result["html_url"]))
print(
"audit:model_catalog_preflight "
f"model={result['id']} max_input_tokens={max_input_tokens or 'unknown'} "
f"max_output_tokens={max_output_tokens or 'unknown'} rate_limit_tier={result['rate_limit_tier'] or 'unknown'}"
)
return 0
def changed_files_since(last_tag: str) -> list[str]:
if last_tag == "none":
output = run_git(["ls-tree", "-r", "--name-only", "HEAD"])
else:
output = run_git(["diff", "--name-only", f"{last_tag}..HEAD"])
return sorted(path for path in output.splitlines() if path)
def category_for(path: str) -> str:
if path == "pom.xml" or path.endswith("/pom.xml"):
return "build metadata"
if "/src/main/" in f"/{path}":
return "production code"
if "/src/test/" in f"/{path}":
return "tests"
if path.startswith(".github/workflows/"):
return "workflows"
if path.startswith("scripts/"):
return "release/tooling scripts" if "release" in path or path.startswith("scripts/tests/") else "scripts"
if path == "CHANGELOG.md" or path.startswith("release/") or path == "RELEASE_PROCESS.md" or path == "README.md":
return "release documentation"
return "other"
def extract_unreleased_changelog() -> str:
path = pathlib.Path("CHANGELOG.md")
if not path.exists():
return "(CHANGELOG.md not found)"
lines = path.read_text(encoding="utf-8").splitlines()
captured: list[str] = []
in_section = False
for line in lines:
if re.match(r"^##\s+\[?Unreleased\]?", line):
in_section = True
continue
if in_section and line.startswith("## "):
break
if in_section:
captured.append(line)
text = "\n".join(captured).strip()
return text or "(Unreleased section is empty)"
def collect_diff(last_tag: str, paths: list[str], max_chars: int) -> tuple[str, bool]:
if last_tag == "none" or not paths:
return "(No prior release tag diff available.)", False
priority = sorted(
paths,
key=lambda path: (
0 if category_for(path) in {"production code", "build metadata"} else 1,
path,
),
)
try:
diff = run_git(["diff", "--no-ext-diff", "--find-renames", "--unified=80", f"{last_tag}..HEAD", "--", *priority])
except subprocess.CalledProcessError as exc:
diff = exc.stdout + "\n" + exc.stderr
redacted = redact(diff)
if len(redacted) > max_chars:
return redacted[:max_chars] + "\n\n[TRUNCATED: selected diff exceeded dossier budget]\n", True
return redacted, False
def public_api_signals(diff_text: str) -> list[str]:
signals: list[str] = []
pattern = re.compile(
r"^[+-]\s*(?:public|protected)\s+(?:static\s+|final\s+|abstract\s+|default\s+|sealed\s+|non-sealed\s+)*"
r"(?:class|interface|enum|record|@interface|[A-Za-z0-9_<>\[\], ?]+)\s+[A-Za-z0-9_]+",
re.MULTILINE,
)
for match in pattern.finditer(diff_text):
line = match.group(0).strip()
if line not in signals:
signals.append(line)
if len(signals) >= 80:
signals.append("[TRUNCATED: more public API signal lines omitted]")
break
return signals
def javadoc_signals(diff_text: str) -> list[str]:
signals: list[str] = []
for line in diff_text.splitlines():
stripped = line.strip()
if not stripped.startswith(("+", "-")):
continue
if "@since" in stripped or "/**" in stripped or stripped.startswith(("+ *", "- *")):
signals.append(stripped)
if len(signals) >= 80:
signals.append("[TRUNCATED: more Javadoc signal lines omitted]")
break
return signals
def command_build_dossier(args: argparse.Namespace) -> int:
files = changed_files_since(args.last_tag)
categories: dict[str, list[str]] = {}
for path in files:
categories.setdefault(category_for(path), []).append(path)
diff_text, diff_truncated = collect_diff(args.last_tag, files, args.max_diff_chars)
api_signals = public_api_signals(diff_text)
doc_signals = javadoc_signals(diff_text)
test_files = [path for path in files if category_for(path) == "tests"]
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
audit = {
"generated_at": generated_at,
"last_tag": args.last_tag,
"current_version": args.current_version,
"pom_base": args.pom_base,
"changed_file_count": len(files),
"category_counts": {key: len(value) for key, value in sorted(categories.items())},
"selected_diff_chars": len(diff_text),
"selected_diff_truncated": diff_truncated,
"public_api_signal_count": len(api_signals),
"javadoc_signal_count": len(doc_signals),
"test_file_count": len(test_files),
}
sections = [
"# ta4j Release Dossier",
"",
"## Metadata",
"",
f"- generated_at: {generated_at}",
f"- current_version: {args.current_version}",
f"- pom_base: {args.pom_base}",
f"- last_reachable_tag: {args.last_tag}",
f"- changed_file_count: {len(files)}",
"",
"## Changed Files by Category",
"",
]
for category in sorted(categories):
sections.append(f"### {category} ({len(categories[category])})")
sections.extend(f"- `{path}`" for path in categories[category])
sections.append("")
sections.extend(
[
"## Unreleased Changelog Context",
"",
"```markdown",
redact(extract_unreleased_changelog()),
"```",
"",
"## Public API Signals",
"",
]
)
sections.extend(f"- `{line}`" for line in api_signals) if api_signals else sections.append("- (none detected)")
sections.extend(["", "## Javadoc and @since Signals", ""])
sections.extend(f"- `{line}`" for line in doc_signals) if doc_signals else sections.append("- (none detected)")
sections.extend(["", "## Test File Signals", ""])
sections.extend(f"- `{path}`" for path in test_files) if test_files else sections.append("- (none detected)")
sections.extend(
[
"",
"## Selected Diff",
"",
"```diff",
diff_text,
"```",
"",
]
)
write_text(args.output, "\n".join(sections))
write_json(args.audit_output, audit)
print(
"audit:release_dossier "
f"file={args.output} changed_files={len(files)} selected_diff_chars={len(diff_text)} "
f"selected_diff_truncated={'true' if diff_truncated else 'false'}"
)
append_output("dossier_path", str(args.output))
append_output("audit_path", str(args.audit_output))
append_output("changed_file_count", str(len(files)))
append_output("selected_diff_chars", str(len(diff_text)))
append_output("selected_diff_truncated", "true" if diff_truncated else "false")
return 0
def command_build_ai_request(args: argparse.Namespace) -> int:
dossier = args.dossier.read_text(encoding="utf-8")
if len(dossier) > args.max_dossier_chars:
dossier = dossier[: args.max_dossier_chars] + "\n\n[TRUNCATED: dossier exceeded request budget]\n"
if args.semver_rules.exists() and args.semver_rules.stat().st_size > 0:
semver_rules = args.semver_rules.read_text(encoding="utf-8")
rules_source = str(args.semver_rules)
else:
semver_rules = textwrap.dedent(
"""\
Decide release go/no-go from unreleased binary-impacting changes.
If go, choose bump: patch|minor.
MINOR: backward-compatible, user-visible new features, and breaking-change cases.
PATCH: backward-compatible bug fixes or internal improvements.
If binary change count is 0 => should_release=false.
If binary change list empty or changelog-only => should_release=false.
If unsure between MINOR and PATCH, prefer PATCH.
Pre-1.0.0: breaking changes can be MINOR.
"""
).strip()
rules_source = "default"
request = {
"model": args.model,
"temperature": 0,
"messages": [
{
"role": "system",
"content": (
"You are a SemVer release reviewer for a Java library. "
"Return JSON only. Base every conclusion on the release dossier."
),
},
{
"role": "user",
"content": (
"Decide whether ta4j should cut a release from this dossier. "
"If yes, choose bump patch or minor. Major is disabled for this workflow.\n\n"
"SemVer rules:\n"
f"{semver_rules}\n\n"
"Return JSON only with this shape:\n"
"{"
"\"should_release\": true|false, "
"\"bump\": \"patch|minor\", "
"\"confidence\": 0.0-1.0, "
"\"reason\": \"1-2 sentences\", "
"\"evidence\": [\"specific dossier facts\"], "
"\"risks\": [\"release risks or empty array\"], "
"\"missing\": [\"missing changelog/javadoc/test evidence or empty array\"]"
"}.\n\n"
f"{dossier}"
),
},
],
}
write_json(args.output, request)
request_size = args.output.stat().st_size
print(
"audit:ai_request "
f"file={args.output} model={args.model} semver_rules_source={rules_source} request_json_size_bytes={request_size}"
)
append_output("request_json_size_bytes", str(request_size))
append_output("dossier_chars", str(len(dossier)))
return 0
def parse_json_object(raw: str) -> dict[str, Any] | None:
candidate = raw.strip()
if candidate.startswith("```"):
candidate = re.sub(r"^```(?:json)?\s*", "", candidate)
candidate = re.sub(r"\s*```$", "", candidate)
try:
parsed = json.loads(candidate)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
pass
start = candidate.find("{")
end = candidate.rfind("}")
if start >= 0 and end > start:
try:
parsed = json.loads(candidate[start : end + 1])
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def parse_release_flag(value: Any) -> tuple[bool, str]:
if isinstance(value, bool):
return value, ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
if value == 1:
return True, ""
if value == 0:
return False, ""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y", "on"}:
return True, ""
if normalized in {"false", "0", "no", "n", "off", ""}:
return False, ""
return False, f"invalid should_release '{value}', defaulted to false"
def normalize_decision(parsed: dict[str, Any] | None, raw: str) -> dict[str, Any]:
if parsed is None:
hint = raw[:200].replace("\n", " ").strip()
reason = "AI response was not valid JSON"
if hint:
reason = f"{reason}: {hint}"
return {
"should_release": False,
"bump": "patch",
"confidence": 0.0,
"warning": "Invalid AI JSON",
"reason": reason,
"evidence": [],
"risks": ["AI response could not be parsed"],
"missing": [],
}
should_release, flag_warning = parse_release_flag(parsed.get("should_release", False))
bump = str(parsed.get("bump") or "patch").strip().lower()
warning = str(parsed.get("warning") or "")
if flag_warning:
warning = (warning + "; " if warning else "") + flag_warning
if bump == "major":
bump = "minor"
warning = (warning + "; " if warning else "") + "major bump disabled, downgraded to minor"
if bump not in {"patch", "minor"}:
warning = (warning + "; " if warning else "") + f"invalid bump '{bump}', defaulted to patch"
bump = "patch"
if not should_release:
bump = "patch"
confidence_raw = parsed.get("confidence", 0.0)
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
def string_list(key: str) -> list[str]:
value = parsed.get(key, [])
if not isinstance(value, list):
return [str(value)]
return [str(item) for item in value]
return {
"should_release": should_release,
"bump": bump,
"confidence": confidence,
"warning": warning,
"reason": str(parsed.get("reason") or ""),
"evidence": string_list("evidence"),
"risks": string_list("risks"),
"missing": string_list("missing"),
}
def command_parse_decision(args: argparse.Namespace) -> int:
raw = args.raw_file.read_text(encoding="utf-8") if args.raw_file.exists() else ""
decision = normalize_decision(parse_json_object(raw), raw)
write_json(args.output, decision)
append_output("should_release", "true" if decision["should_release"] else "false", args.github_output)
append_output("bump", str(decision["bump"]), args.github_output)
append_output("confidence", str(decision["confidence"]), args.github_output)
append_output("warning", str(decision["warning"]), args.github_output)
append_output("reason", str(decision["reason"]), args.github_output)
print(
"audit:ai_decision "
f"should_release={'true' if decision['should_release'] else 'false'} bump={decision['bump']} "
f"confidence={decision['confidence']} output={args.output}"
)
return 0
def normalize_javadoc_warning(line: str) -> str | None:
value = line.strip()
if not value:
return None
value = re.sub(r"^\[[A-Z]+\]\s*", "", value)
if value == "Javadoc Warnings":
return None
lower = value.lower()
is_javadoc_warning = (
("javadoc" in lower and "warning" in lower)
or re.search(r"\bwarning\s*[-:]", lower) is not None
)
if not is_javadoc_warning:
return None
# Compiler deprecation diagnostics are Maven warnings, not Javadoc debt.
if re.search(r"\.java:\[\d+,\d+\]", value):
return None
path_match = JAVADOC_PATH_PATTERN.match(value)
if path_match:
value = path_match.group(1)
value = re.sub(r"(\.java):\d+:\s+warning:", r"\1: warning:", value)
return redact_log_path(value)
def unique_preserving_order(values: list[str]) -> list[str]:
seen: set[str] = set()
unique: list[str] = []
for value in values:
if value not in seen:
seen.add(value)
unique.append(value)
return unique
def collect_javadoc_warnings(logs: list[pathlib.Path]) -> tuple[list[str], list[str]]:
warnings: list[str] = []
missing_logs: list[str] = []
for log_file in logs:
if not log_file.exists():
missing_logs.append(str(log_file))
continue
for line in log_file.read_text(encoding="utf-8", errors="replace").splitlines():
warning = normalize_javadoc_warning(line)
if warning:
warnings.append(warning)
return unique_preserving_order(warnings), missing_logs
def load_javadoc_baseline(path: pathlib.Path) -> list[str]:
if not path.exists():
return []
values: list[str] = []
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if line and not line.startswith("#"):
normalized = normalize_javadoc_warning(line)
values.append(normalized if normalized else line)
return unique_preserving_order(values)
def command_javadoc_warnings(args: argparse.Namespace) -> int:
current, missing_logs = collect_javadoc_warnings(args.logs)
baseline = load_javadoc_baseline(args.baseline)
baseline_set = set(baseline)
current_set = set(current)
new_warnings = [warning for warning in current if warning not in baseline_set]
resolved_warnings = [warning for warning in baseline if warning not in current_set]
lines = [
"# Javadoc Warning Baseline Check",
"",
f"baseline={args.baseline}",
f"current_count={len(current)}",
f"baseline_count={len(baseline)}",
f"new_count={len(new_warnings)}",
f"resolved_count={len(resolved_warnings)}",
"",
"## Current warnings",
*(current if current else ["(none)"]),
"",
"## New warnings",
*(new_warnings if new_warnings else ["(none)"]),
"",
"## Baseline warnings not seen in this run",
*(resolved_warnings if resolved_warnings else ["(none)"]),
"",
"## Missing logs",
*(missing_logs if missing_logs else ["(none)"]),
"",
]
write_text(args.output, "\n".join(lines))
append_output("javadoc_warning_count", str(len(current)), args.github_output)
append_output("javadoc_warning_baseline_count", str(len(baseline)), args.github_output)
append_output("javadoc_warning_new_count", str(len(new_warnings)), args.github_output)
append_output("javadoc_warning_resolved_count", str(len(resolved_warnings)), args.github_output)
print(
"audit:javadoc_warnings "
f"current={len(current)} baseline={len(baseline)} new={len(new_warnings)} "
f"resolved={len(resolved_warnings)} output={args.output}"
)
for missing_log in missing_logs:
print(f"::warning::Javadoc warning log not found: {missing_log}")
if new_warnings and args.fail_on_new:
for warning in new_warnings:
print(f"::error::New Javadoc warning: {warning}", file=sys.stderr)
return 1
return 0
def command_artifact_manifest(args: argparse.Namespace) -> int:
version = args.version.strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print(f"::error::Release artifact version must be major.minor.patch: {version}", file=sys.stderr)
return 1
expected = [pathlib.Path(template.format(version=version)) for template in EXPECTED_RELEASE_ARTIFACTS]
existing_jars = sorted(pathlib.Path(".").glob("*/target/*.jar"))
expected_set = {path.as_posix() for path in expected}
existing_set = {path.as_posix().removeprefix("./") for path in existing_jars}
missing = sorted(path.as_posix() for path in expected if not path.exists())
unexpected = sorted(path for path in existing_set if path not in expected_set)
manifest_lines = [
f"version={version}",
"",
"Expected release artifacts:",
*[f"- {path.as_posix()}" for path in expected],
"",
"Missing release artifacts:",
*([f"- {path}" for path in missing] if missing else ["- (none)"]),
"",
"Unexpected target jars:",
*([f"- {path}" for path in unexpected] if unexpected else ["- (none)"]),
"",
]
write_text(args.output, "\n".join(manifest_lines))
files_output = "\n".join(path.as_posix() for path in expected)
append_output("files", files_output, args.github_output)
append_output("artifact_manifest", str(args.output), args.github_output)
append_output("missing_count", str(len(missing)), args.github_output)
append_output("unexpected_count", str(len(unexpected)), args.github_output)
print(
"audit:artifact_manifest "
f"version={version} expected={len(expected)} missing={len(missing)} unexpected={len(unexpected)} output={args.output}"
)
if missing or (unexpected and args.strict):
if missing:
print(f"::error::Missing release artifacts: {', '.join(missing)}", file=sys.stderr)
if unexpected and args.strict:
print(f"::error::Unexpected target jars: {', '.join(unexpected)}", file=sys.stderr)
return 1
return 0
def snapshot_publication_result(
*,
version: str,
published: str,
latest: str,
last_updated: str,
source: str,
error: str = "",
versions: list[str] | None = None,
) -> dict[str, Any]:
"""Return a stable snapshot-publication payload for workflow consumers."""
return {
"version": version,
"published": published,
"latest": latest,
"lastUpdated": last_updated,
"source": source,
"error": error,
"versions": versions or [],
}
def emit_snapshot_publication_outputs(result: dict[str, Any], github_output: str | None) -> None:
"""Expose snapshot-publication fields through the GitHub Actions output contract."""
append_output("snapshot_publication", str(result["published"]), github_output)
append_output("snapshot_publication_latest", str(result["latest"]), github_output)
append_output("snapshot_publication_last_updated", str(result["lastUpdated"]), github_output)
append_output("snapshot_publication_source", str(result["source"]), github_output)
append_output("snapshot_publication_error", str(result["error"]), github_output)
def snapshot_publication_policy_result(
*,
event_name: str,
workflow_name: str,
enforce: bool,
pending_reason: str,
) -> dict[str, Any]:
"""Describe when release-health should treat snapshot metadata as authoritative."""
return {
"eventName": event_name,
"workflowName": workflow_name,
"enforce": enforce,
"pendingReason": pending_reason,
}
def emit_snapshot_publication_policy_outputs(result: dict[str, Any], github_output: str | None) -> None:
"""Expose snapshot-publication policy fields through the GitHub Actions output contract."""
append_output("snapshot_publication_enforced", "true" if result["enforce"] else "false", github_output)
append_output("snapshot_publication_pending_reason", str(result["pendingReason"]), github_output)
def validate_snapshot_metadata_url(metadata_url: str) -> str:
"""Reject non-HTTPS metadata URLs; local fixtures must use --metadata-file instead."""
parsed = urlparse(metadata_url.strip())
if parsed.scheme.lower() != "https" or not parsed.netloc:
raise ValueError("--metadata-url must use https")
return metadata_url
def load_snapshot_metadata(args: argparse.Namespace) -> tuple[str, str]:
"""Load snapshot metadata XML from a local fixture or the live snapshot repository."""
if args.metadata_file:
source = args.metadata_file.resolve().as_uri()
return args.metadata_file.read_text(encoding="utf-8"), source
metadata_url = validate_snapshot_metadata_url(args.metadata_url)
request = urllib.request.Request(
metadata_url,
headers={
"Accept": "application/xml",
"User-Agent": "ta4j-release-automation",
},
)
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
return response.read().decode("utf-8"), metadata_url
def parse_snapshot_metadata(metadata_text: str) -> tuple[str, str, list[str]]:
"""Extract latest version, timestamp, and published versions from metadata XML."""
root = ET.fromstring(metadata_text)
versioning = root.find("versioning")
if versioning is None:
raise ValueError("snapshot metadata is missing the <versioning> section")
latest = (versioning.findtext("latest") or "").strip()
last_updated = (versioning.findtext("lastUpdated") or "").strip()
versions_parent = versioning.find("versions")
versions = []
if versions_parent is not None:
versions = [
(node.text or "").strip()
for node in versions_parent.findall("version")
if (node.text or "").strip()
]
return latest, last_updated, versions
def command_snapshot_publication(args: argparse.Namespace) -> int:
"""Resolve whether the requested ta4j snapshot version is currently published."""
version = args.version.strip()
if not version:
print("::error::--version is required", file=sys.stderr)
return 1
if not version.endswith("-SNAPSHOT"):
result = snapshot_publication_result(
version=version,
published="n/a",
latest="",
last_updated="",
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(f"audit:snapshot_publication version={version} published=n/a output={args.output}")
return 0
try:
metadata_text, source = load_snapshot_metadata(args)
latest, last_updated, versions = parse_snapshot_metadata(metadata_text)
except (ET.ParseError, OSError, UnicodeDecodeError, ValueError) as exc:
result = snapshot_publication_result(
version=version,
published="unknown",
latest="",
last_updated="",
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
error=str(exc),
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(f"::warning::Unable to verify snapshot publication for {version}: {exc}")
print(f"audit:snapshot_publication version={version} published=unknown output={args.output}")
return 0
published = "true" if version in set(versions) else "false"
result = snapshot_publication_result(
version=version,
published=published,
latest=latest,
last_updated=last_updated,
source=source,
versions=versions,
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(
"audit:snapshot_publication "
f"version={version} published={published} latest={latest or 'unknown'} "
f"last_updated={last_updated or 'unknown'} output={args.output}"
)
return 0
def command_snapshot_publication_policy(args: argparse.Namespace) -> int:
"""Decide whether snapshot-publication failures should count as release-health drift."""
event_name = args.event_name.strip()
workflow_name = args.workflow_name.strip()
enforce = event_name in {"schedule", "workflow_dispatch"} or (
event_name == "workflow_run" and workflow_name == SNAPSHOT_WORKFLOW_NAME
)
pending_reason = (
""
if enforce
else "awaiting snapshot workflow completion before treating snapshot metadata drift as authoritative"
)
result = snapshot_publication_policy_result(
event_name=event_name,
workflow_name=workflow_name,
enforce=enforce,
pending_reason=pending_reason,
)
write_json(args.output, result)
emit_snapshot_publication_policy_outputs(result, args.github_output)
print(
"audit:snapshot_publication_policy "
f"event={event_name or 'unknown'} workflow={workflow_name or 'none'} "
f"enforced={'true' if enforce else 'false'} output={args.output}"
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
catalog = subparsers.add_parser("catalog-preflight")
catalog.add_argument("--model", required=True)
catalog.add_argument("--catalog-url", default="https://models.github.ai/catalog/models")
catalog.add_argument("--catalog-file")
catalog.add_argument("--timeout-seconds", type=int, default=30)
catalog.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-ai-model.json"))
catalog.set_defaults(func=command_catalog_preflight)
dossier = subparsers.add_parser("build-dossier")
dossier.add_argument("--last-tag", required=True)
dossier.add_argument("--current-version", required=True)
dossier.add_argument("--pom-base", required=True)
dossier.add_argument("--max-diff-chars", type=int, default=600_000)
dossier.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
dossier.add_argument("--audit-output", type=pathlib.Path, default=pathlib.Path("release-audit.json"))
dossier.set_defaults(func=command_build_dossier)
request = subparsers.add_parser("build-ai-request")
request.add_argument("--model", required=True)
request.add_argument("--dossier", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
request.add_argument("--semver-rules", type=pathlib.Path, default=pathlib.Path(".github/workflows/semver-rules-override.txt"))
request.add_argument("--max-dossier-chars", type=int, default=900_000)
request.add_argument("--output", type=pathlib.Path, default=pathlib.Path("request.json"))
request.set_defaults(func=command_build_ai_request)
parse = subparsers.add_parser("parse-decision")
parse.add_argument("--raw-file", type=pathlib.Path, default=pathlib.Path("ai-content.txt"))
parse.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-decision.json"))
parse.add_argument("--github-output")
parse.set_defaults(func=command_parse_decision)
javadoc = subparsers.add_parser("javadoc-warnings")
javadoc.add_argument("--baseline", type=pathlib.Path, default=pathlib.Path("scripts/release/javadoc-warning-baseline.txt"))
javadoc.add_argument("--output", type=pathlib.Path, default=pathlib.Path("javadoc-warnings.txt"))
javadoc.add_argument("--github-output")
javadoc.add_argument("--fail-on-new", action="store_true")
javadoc.add_argument("logs", type=pathlib.Path, nargs="*")
javadoc.set_defaults(func=command_javadoc_warnings)
manifest = subparsers.add_parser("artifact-manifest")
manifest.add_argument("--version", required=True)
manifest.add_argument("--output", type=pathlib.Path, default=pathlib.Path("artifact-manifest.txt"))
manifest.add_argument("--github-output")
manifest.add_argument("--strict", action="store_true")
manifest.set_defaults(func=command_artifact_manifest)
snapshot = subparsers.add_parser("snapshot-publication")
snapshot.add_argument("--version", required=True)
snapshot.add_argument("--metadata-url", default=SNAPSHOT_METADATA_URL)
snapshot.add_argument("--metadata-file", type=pathlib.Path)
snapshot.add_argument("--timeout-seconds", type=int, default=30)
snapshot.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication.json"))
snapshot.add_argument("--github-output")
snapshot.set_defaults(func=command_snapshot_publication)
snapshot_policy = subparsers.add_parser("snapshot-publication-policy")
snapshot_policy.add_argument("--event-name", required=True)
snapshot_policy.add_argument("--workflow-name", default="")
snapshot_policy.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication-policy.json"))
snapshot_policy.add_argument("--github-output")
snapshot_policy.set_defaults(func=command_snapshot_publication_policy)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return int(args.func(args))
except Exception as exc:
print(f"::error::{exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# resolve-release-tags.sh
#
# Emits release-tag state as key=value lines for the supplied target ref.
#
# Usage:
# scripts/resolve-release-tags.sh [target-ref]
#
# Output:
# latest_tag=<tag|none>
# last_reachable_tag=<tag|none>
# last_first_parent_tag=<tag|none>
# latest_tag_reachable=<true|false|n/a>
# =============================================================================
target_ref="${1:-HEAD}"
if ! git rev-parse --verify --quiet "${target_ref}^{commit}" >/dev/null; then
target_ref="HEAD"
fi
list_tags() {
local merged_ref="${1:-}"
if [ -n "$merged_ref" ]; then
git for-each-ref --sort=-creatordate --merged "$merged_ref" --format='%(refname:strip=2)' refs/tags
else
git for-each-ref --sort=-creatordate --format='%(refname:strip=2)' refs/tags
fi
}
find_preferred_release_tag() {
local merged_ref="${1:-}"
local selected_tag="none"
local tag=""
local tag_list=""
tag_list="$(list_tags "$merged_ref")"
if [ -n "$tag_list" ]; then
while IFS= read -r tag; do
case "$tag" in
[0-9]*)
selected_tag="$tag"
break
;;
esac
done <<< "$tag_list"
if [ "$selected_tag" = "none" ]; then
while IFS= read -r tag; do
case "$tag" in
v[0-9]*)
selected_tag="$tag"
break
;;
esac
done <<< "$tag_list"
fi
fi
printf '%s\n' "$selected_tag"
}
describe_first_parent_release_tag() {
local ref="$1"
local tag=""
tag=$(git describe --tags --abbrev=0 --first-parent --match '[0-9]*' "$ref" 2>/dev/null || true)
if [ -z "$tag" ]; then
tag=$(git describe --tags --abbrev=0 --first-parent --match 'v[0-9]*' "$ref" 2>/dev/null || true)
fi
if [ -z "$tag" ]; then
printf 'none\n'
return 0
fi
printf '%s\n' "$tag"
}
latest_tag="$(find_preferred_release_tag)"
last_reachable_tag="$(find_preferred_release_tag "$target_ref")"
last_first_parent_tag="$(describe_first_parent_release_tag "$target_ref")"
latest_tag_reachable="n/a"
if [ "$latest_tag" != "none" ]; then
if git merge-base --is-ancestor "$latest_tag" "$target_ref"; then
latest_tag_reachable="true"
else
latest_tag_reachable="false"
fi
fi
printf 'latest_tag=%s\n' "$latest_tag"
printf 'last_reachable_tag=%s\n' "$last_reachable_tag"
printf 'last_first_parent_tag=%s\n' "$last_first_parent_tag"
printf 'latest_tag_reachable=%s\n' "$latest_tag_reachable"
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env bash
set -euo pipefail
UNAME_OUT="$(uname -s 2>/dev/null || echo unknown)"
RUNNING_IN_MINGW="false"
case "$UNAME_OUT" in
CYGWIN*|MSYS*|MINGW*) RUNNING_IN_MINGW="true" ;;
esac
to_host_path() {
local posix_path="$1"
if [[ "$RUNNING_IN_MINGW" == "true" ]]; then
if command -v cygpath >/dev/null 2>&1; then
cygpath -w "$posix_path"
return
fi
if [[ "$posix_path" =~ ^/([a-zA-Z])/(.*)$ ]]; then
local drive="${BASH_REMATCH[1]}"
local remainder="${BASH_REMATCH[2]}"
drive="$(printf '%s' "$drive" | tr '[:lower:]' '[:upper:]')"
if [[ -n "$remainder" ]]; then
printf '%s:/%s' "$drive" "$remainder"
else
printf '%s:/' "$drive"
fi
return
fi
fi
printf '%s' "$posix_path"
}
TMP_FILES=()
build_runner_pid=""
heartbeat_pid=""
cleanup() {
if [[ -n "${build_runner_pid:-}" ]] && kill -0 "$build_runner_pid" >/dev/null 2>&1; then
kill "$build_runner_pid" >/dev/null 2>&1 || true
fi
if [[ -n "${heartbeat_pid:-}" ]] && kill -0 "$heartbeat_pid" >/dev/null 2>&1; then
kill "$heartbeat_pid" >/dev/null 2>&1 || true
fi
if [[ -n "${build_runner_pid:-}" ]]; then
wait "$build_runner_pid" >/dev/null 2>&1 || true
fi
if [[ -n "${heartbeat_pid:-}" ]]; then
wait "$heartbeat_pid" >/dev/null 2>&1 || true
fi
local file
for file in "${TMP_FILES[@]:-}"; do
if [[ -n "${file:-}" && -e "$file" ]]; then
rm -f "$file"
fi
done
}
trap cleanup SIGINT SIGTERM EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
LOG_DIR="$REPO_ROOT/.agents/logs"
mkdir -p "$LOG_DIR"
# Clean up old full build logs, keeping only the last 10
cleanup_old_logs() {
local log_dir="$1"
local keep_count=10
if [[ ! -d "$log_dir" ]]; then
return 0
fi
# Find all full-build-*.log files and sort by filename (which contains timestamp)
# Filenames are in format: full-build-YYYYMMDD-HHMMSS.log
# Sorting alphabetically gives chronological order (newest last)
local files
files=$(find "$log_dir" -maxdepth 1 -type f -name "full-build-*.log" 2>/dev/null | sort || true)
if [[ -z "$files" ]]; then
return 0
fi
# Delete everything except the newest keep_count files; use POSIX-safe tail/awk
local file_count
file_count=$(printf '%s\n' "$files" | grep -c . || true)
local delete_count=$((file_count - keep_count))
if ((delete_count > 0)); then
local files_to_delete
files_to_delete=$(printf '%s\n' "$files" | head -n "$delete_count")
while IFS= read -r file; do
if [[ -n "$file" && -f "$file" ]]; then
rm -f "$file"
fi
done <<< "$files_to_delete"
fi
}
cleanup_old_logs "$LOG_DIR"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/full-build-${TIMESTAMP}.log"
LOG_FILE_FOR_DISPLAY="$LOG_FILE"
if [[ "$RUNNING_IN_MINGW" == "true" ]]; then
LOG_FILE_FOR_DISPLAY="$(to_host_path "$LOG_FILE")"
fi
BUILD_TIMEOUT_SECONDS="${QUIET_BUILD_TIMEOUT_SECONDS:-180}"
if ! [[ "$BUILD_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]]; then
BUILD_TIMEOUT_SECONDS=180
fi
HEARTBEAT_INTERVAL_SECONDS="${QUIET_BUILD_HEARTBEAT_SECONDS:-60}"
if ! [[ "$HEARTBEAT_INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || ((HEARTBEAT_INTERVAL_SECONDS < 1)); then
HEARTBEAT_INTERVAL_SECONDS=60
fi
MAVEN_FLAGS=(
-B
-ntp
-Dstyle.color=never
-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN
)
EXTRA_MAVEN_ARGS=()
if (($# > 0)); then
EXTRA_MAVEN_ARGS=("$@")
fi
if ((${#EXTRA_MAVEN_ARGS[@]} > 0)); then
printf 'Forwarding extra Maven args:'
for arg in "${EXTRA_MAVEN_ARGS[@]}"; do
printf ' %q' "$arg"
done
printf '\n'
fi
GOALS=(clean license:format formatter:format test install)
MAVEN_CMD_PREFIX=(mvn)
if [[ -x "./mvnw" ]]; then
MAVEN_CMD_PREFIX=("./mvnw")
echo "Using Maven Wrapper: ./mvnw"
elif [[ -f "./mvnw" ]]; then
MAVEN_CMD_PREFIX=(sh "./mvnw")
echo "Using Maven Wrapper via sh: ./mvnw"
else
echo "Using system Maven from PATH: mvn"
fi
MVN_CMD=("${MAVEN_CMD_PREFIX[@]}" "${MAVEN_FLAGS[@]}")
if ((${#EXTRA_MAVEN_ARGS[@]} > 0)); then
MVN_CMD+=("${EXTRA_MAVEN_ARGS[@]}")
fi
MVN_CMD+=("${GOALS[@]}")
echo "Running ta4j full build quietly..."
echo "Full log: $LOG_FILE_FOR_DISPLAY"
echo
format_elapsed() {
local total_seconds="$1"
local hours=$((total_seconds / 3600))
local minutes=$(((total_seconds % 3600) / 60))
local seconds=$((total_seconds % 60))
if ((hours > 0)); then
printf '%dh%02dm%02ds' "$hours" "$minutes" "$seconds"
elif ((minutes > 0)); then
printf '%dm%02ds' "$minutes" "$seconds"
else
printf '%ds' "$seconds"
fi
}
should_print_line() {
local text="$1"
case "$text" in
*"[ERROR]"*|*"[WARNING]"*|*"BUILD SUCCESS"*|*"BUILD FAILURE"*|*"Failed to execute goal"*|*"Reactor Summary"*)
return 0
;;
*)
return 1
;;
esac
}
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
run_with_timeout() {
local timeout_marker_file="$1"
local timeout_seconds="$2"
shift 2
: >"$timeout_marker_file"
"$@" &
local command_pid=$!
(
local elapsed=0
while ((elapsed < timeout_seconds)); do
sleep 1
if ! kill -0 "$command_pid" >/dev/null 2>&1; then
exit 0
fi
elapsed=$((elapsed + 1))
done
if kill -0 "$command_pid" >/dev/null 2>&1; then
echo "timeout" >"$timeout_marker_file"
kill -TERM "$command_pid" >/dev/null 2>&1 || true
sleep 5
if kill -0 "$command_pid" >/dev/null 2>&1; then
kill -KILL "$command_pid" >/dev/null 2>&1 || true
fi
fi
) &
local timeout_watcher_pid=$!
set +e
wait "$command_pid"
local command_status=$?
set -e
wait "$timeout_watcher_pid" >/dev/null 2>&1 || true
if [[ -s "$timeout_marker_file" ]]; then
return 124
fi
return "$command_status"
}
update_last_visible() {
date +%s >"$LAST_VISIBLE_FILE"
}
heartbeat_worker() {
local build_pid="$1"
local start_time="$2"
local heartbeat_interval="$3"
while kill -0 "$build_pid" >/dev/null 2>&1; do
sleep "$heartbeat_interval"
if ! kill -0 "$build_pid" >/dev/null 2>&1; then
break
fi
local now
now="$(date +%s)"
local last_visible="$start_time"
if [[ -f "$LAST_VISIBLE_FILE" ]]; then
last_visible="$(cat "$LAST_VISIBLE_FILE" 2>/dev/null || echo "$start_time")"
fi
if ! [[ "$last_visible" =~ ^[0-9]+$ ]]; then
last_visible="$start_time"
fi
if ((now - last_visible >= heartbeat_interval)); then
echo "[quiet-build] still running... ($(format_elapsed $((now - start_time))))"
update_last_visible
fi
done
}
run_maven_build() {
if ((BUILD_TIMEOUT_SECONDS > 0)); then
run_with_timeout "$TIMEOUT_MARKER_FILE" "$BUILD_TIMEOUT_SECONDS" "${MVN_CMD[@]}"
return $?
fi
"${MVN_CMD[@]}"
}
emit_suppressed_duplicates() {
if [[ ! -s "$SUPPRESSED_DUPLICATES_FILE" ]]; then
return 0
fi
echo
echo "[quiet-build] Suppressed duplicate warnings:"
local line
while IFS= read -r line || [[ -n "$line" ]]; do
if grep -Fqx -- "$line" "$SUPPRESSED_REPORTED_FILE"; then
continue
fi
local count
count=$(grep -Fxc -- "$line" "$SUPPRESSED_DUPLICATES_FILE" || true)
echo "[quiet-build] (${count} more) $(trim_whitespace "$line")"
printf '%s\n' "$line" >>"$SUPPRESSED_REPORTED_FILE"
done <"$SUPPRESSED_DUPLICATES_FILE"
}
extract_test_summary() {
local total_run=0
local total_failures=0
local total_errors=0
local total_skipped=0
local has_aggregated="false"
local fallback_summary=""
local line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^\[(INFO|WARNING)\][[:space:]]+Tests\ run:\ ([0-9]+),\ Failures:\ ([0-9]+),\ Errors:\ ([0-9]+),\ Skipped:\ ([0-9]+)[[:space:]]*$ ]]; then
has_aggregated="true"
total_run=$((total_run + ${BASH_REMATCH[2]}))
total_failures=$((total_failures + ${BASH_REMATCH[3]}))
total_errors=$((total_errors + ${BASH_REMATCH[4]}))
total_skipped=$((total_skipped + ${BASH_REMATCH[5]}))
elif [[ "$line" =~ Tests\ run:\ ([0-9]+),\ Failures:\ ([0-9]+),\ Errors:\ ([0-9]+),\ Skipped:\ ([0-9]+) ]]; then
fallback_summary="Tests run: ${BASH_REMATCH[1]}, Failures: ${BASH_REMATCH[2]}, Errors: ${BASH_REMATCH[3]}, Skipped: ${BASH_REMATCH[4]}"
fi
done <"$LOG_FILE"
if [[ "$has_aggregated" == "true" ]]; then
printf 'Tests run: %d, Failures: %d, Errors: %d, Skipped: %d' "$total_run" "$total_failures" "$total_errors" "$total_skipped"
return 0
fi
if [[ -n "$fallback_summary" ]]; then
printf '%s' "$fallback_summary"
fi
}
LAST_VISIBLE_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-last-visible.XXXXXX")"
TMP_FILES+=("$LAST_VISIBLE_FILE")
BUILD_START_TIME="$(date +%s)"
echo "$BUILD_START_TIME" >"$LAST_VISIBLE_FILE"
SEEN_VISIBLE_LINES_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-seen-visible.XXXXXX")"
TMP_FILES+=("$SEEN_VISIBLE_LINES_FILE")
SUPPRESSED_DUPLICATES_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-suppressed-duplicates.XXXXXX")"
TMP_FILES+=("$SUPPRESSED_DUPLICATES_FILE")
SUPPRESSED_REPORTED_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-suppressed-reported.XXXXXX")"
TMP_FILES+=("$SUPPRESSED_REPORTED_FILE")
OUTPUT_FIFO="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-output.XXXXXX")"
TMP_FILES+=("$OUTPUT_FIFO")
rm -f "$OUTPUT_FIFO"
mkfifo "$OUTPUT_FIFO"
TIMEOUT_MARKER_FILE=""
if ((BUILD_TIMEOUT_SECONDS > 0)); then
TIMEOUT_MARKER_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-timeout-marker.XXXXXX")"
TMP_FILES+=("$TIMEOUT_MARKER_FILE")
fi
: >"$LOG_FILE"
: >"$SEEN_VISIBLE_LINES_FILE"
: >"$SUPPRESSED_DUPLICATES_FILE"
: >"$SUPPRESSED_REPORTED_FILE"
set +o pipefail
set +e
run_maven_build >"$OUTPUT_FIFO" 2>&1 &
build_runner_pid=$!
heartbeat_worker "$build_runner_pid" "$BUILD_START_TIME" "$HEARTBEAT_INTERVAL_SECONDS" &
heartbeat_pid=$!
while IFS= read -r line || [[ -n "$line" ]]; do
printf '%s\n' "$line" >>"$LOG_FILE"
if ! should_print_line "$line"; then
continue
fi
if [[ "$line" == *"Tests run:"* && "$line" == *"Time elapsed"* ]]; then
continue
fi
if grep -Fqx -- "$line" "$SEEN_VISIBLE_LINES_FILE"; then
printf '%s\n' "$line" >>"$SUPPRESSED_DUPLICATES_FILE"
else
printf '%s\n' "$line" >>"$SEEN_VISIBLE_LINES_FILE"
printf '%s\n' "$line"
update_last_visible
fi
done <"$OUTPUT_FIFO"
filter_status=$?
wait "$build_runner_pid"
mvn_status=$?
set -e
set -o pipefail
kill "$heartbeat_pid" >/dev/null 2>&1 || true
wait "$heartbeat_pid" >/dev/null 2>&1 || true
emit_suppressed_duplicates
if ((mvn_status != 0 || filter_status != 0)); then
echo
if ((mvn_status == 124)); then
echo "Maven build timed out after ${BUILD_TIMEOUT_SECONDS}s. Inspect the full log at: $LOG_FILE_FOR_DISPLAY"
exit 1
fi
echo "Maven build failed (mvn=$mvn_status, filter=$filter_status). Inspect the full log at: $LOG_FILE_FOR_DISPLAY"
exit 1
fi
summary="$(extract_test_summary)"
echo
if [[ -n "$summary" ]]; then
echo "$summary"
else
echo "Tests run summary not found in log; see $LOG_FILE_FOR_DISPLAY"
fi
echo "Full build log saved to: $LOG_FILE_FOR_DISPLAY"
@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# test_prepare_release.sh
#
# Tests the responsibilities of prepare-release.sh:
# - CHANGELOG transformation
# - README version updates
# - Release notes file creation
# - Output variables
# =============================================================================
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/scripts/prepare-release.sh"
cleanup() {
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
return 0
}
trap cleanup EXIT
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_contains() {
local haystack="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq "$needle" <<<"$haystack"; then
fail "$msg (missing: '$needle')"
fi
}
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle')"
fi
}
expect_file_matches() {
local file="$1"
local regex="$2"
local msg="$3"
if ! grep -Eq -- "$regex" "$file"; then
fail "$msg (regex mismatch: '$regex')"
fi
}
run_test() {
TMP="$(mktemp -d "${TMPDIR:-/tmp}/prepare-release-test.XXXXXX")"
mkdir -p "$TMP/scripts"
cp "$SCRIPT" "$TMP/scripts/prepare-release.sh"
chmod +x "$TMP/scripts/prepare-release.sh"
pushd "$TMP" >/dev/null || exit 1
}
finish_test() {
popd >/dev/null || exit 1
rm -rf "$TMP"
}
# -----------------------------------------------------------------------------
# TEST 1: Basic Release Flow
# -----------------------------------------------------------------------------
test_basic_flow() {
echo "Running test_basic_flow"
run_test
cat > CHANGELOG.md <<'EOF'
## Unreleased
- Added new Indicator ABC.
## 1.2.3 (2024-01-01)
- Older note
EOF
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<dependency>
<artifactId>ta4j-core</artifactId>
<version>0.0.1</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
<dependency>
<artifactId>ta4j-core</artifactId>
<version>0.0.2-SNAPSHOT</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
<dependency>
<artifactId>ta4j-examples</artifactId>
<version>0.0.1</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
<dependency>
<artifactId>ta4j-examples</artifactId>
<version>0.0.2-SNAPSHOT</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
EOF
OUT="$(scripts/prepare-release.sh 1.3.0)"
expect_contains "$OUT" "release_version=1.3.0" "script should print release version"
expect_contains "$OUT" "release_notes_file=release/1.3.0.md" "script should print release notes file path"
expect_file_contains CHANGELOG.md "## Unreleased" "Unreleased should be preserved"
expect_file_contains CHANGELOG.md "- _No changes yet._" "Unreleased should be reset with placeholder"
expect_file_matches CHANGELOG.md "## 1\\.3\\.0 \\([0-9]{4}-[0-9]{2}-[0-9]{2}\\)" "new release header should exist"
expect_file_contains CHANGELOG.md "Added new Indicator ABC." "notes should move into release section"
expect_file_contains release/1.3.0.md "Added new Indicator ABC." "release notes file should include notes"
expect_file_matches release/1.3.0.md "^## 1\\.3\\.0 \\([0-9]{4}-[0-9]{2}-[0-9]{2}\\)" "release notes should have correct header"
expect_file_contains README.md "<version>1.3.0</version>" "README version should be updated"
expect_file_contains README.md "<version>1.3.1-SNAPSHOT</version>" "README snapshot version should be incremented and preserved"
finish_test
pass "test_basic_flow"
}
# -----------------------------------------------------------------------------
# TEST 2: Missing Unreleased Section
# -----------------------------------------------------------------------------
test_missing_unreleased() {
echo "Running test_missing_unreleased"
run_test
cat > CHANGELOG.md <<'EOF'
## 1.2.0 (2023-12-01)
- Old release
EOF
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<version>0.0.1</version>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
<version>0.0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
<version>0.0.1</version>
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
<version>0.0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
EOF
OUT="$(scripts/prepare-release.sh 1.3.0)"
expect_file_contains CHANGELOG.md "## Unreleased" "should create Unreleased"
expect_file_contains CHANGELOG.md "## 1.3.0" "should add release section"
finish_test
pass "test_missing_unreleased"
}
# -----------------------------------------------------------------------------
# TEST 3: Empty CHANGELOG
# -----------------------------------------------------------------------------
test_empty_changelog() {
echo "Running test_empty_changelog"
run_test
touch CHANGELOG.md
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<version>0.0.1</version>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
<version>0.0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
<version>0.0.1</version>
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
<version>0.0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
EOF
OUT="$(scripts/prepare-release.sh 1.0.0)"
expect_file_contains CHANGELOG.md "## Unreleased" "should add Unreleased"
expect_file_contains CHANGELOG.md "## 1.0.0" "should add release section"
expect_file_contains CHANGELOG.md "- _No changes yet._" "should add placeholder"
finish_test
pass "test_empty_changelog"
}
# -----------------------------------------------------------------------------
# TEST 4: Snapshot Version Increment (major.minor.patch)
# -----------------------------------------------------------------------------
test_snapshot_patch_increment() {
echo "Running test_snapshot_patch_increment"
run_test
cat > CHANGELOG.md <<'EOF'
## Unreleased
- Some change.
EOF
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<dependency>
<artifactId>ta4j-core</artifactId>
<version>0.1</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
<dependency>
<artifactId>ta4j-core</artifactId>
<version>0.2-SNAPSHOT</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
<dependency>
<artifactId>ta4j-examples</artifactId>
<version>0.1</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
<dependency>
<artifactId>ta4j-examples</artifactId>
<version>0.2-SNAPSHOT</version>
</dependency>
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
EOF
scripts/prepare-release.sh 2.7.4 >/dev/null
expect_file_contains README.md "<version>2.7.4</version>" "README stable version should be updated"
expect_file_contains README.md "<version>2.7.5-SNAPSHOT</version>" "README snapshot version should increment patch and preserve suffix"
finish_test
pass "test_snapshot_patch_increment"
}
# -----------------------------------------------------------------------------
# TEST 5: Missing README Sentinel
# -----------------------------------------------------------------------------
test_missing_readme_sentinel() {
echo "Running test_missing_readme_sentinel"
run_test
cat > CHANGELOG.md <<'EOF'
## Unreleased
- Some change.
EOF
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<version>0.1</version>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
EOF
if scripts/prepare-release.sh 1.0.0 >/dev/null 2>&1; then
fail "script should fail when README sentinels are missing"
fi
finish_test
pass "test_missing_readme_sentinel"
}
# -----------------------------------------------------------------------------
# TEST 6: Invalid Release Version
# -----------------------------------------------------------------------------
test_invalid_release_version() {
echo "Running test_invalid_release_version"
run_test
cat > CHANGELOG.md <<'EOF'
## Unreleased
- Some change.
EOF
cat > README.md <<'EOF'
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
<version>0.1</version>
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
<version>0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
<version>0.1</version>
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
<version>0.1-SNAPSHOT</version>
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
EOF
if scripts/prepare-release.sh 1.3 >/dev/null 2>&1; then
fail "script should fail when release version is not major.minor.patch"
fi
expect_file_contains CHANGELOG.md "## Unreleased" "changelog should remain unchanged on version error"
expect_file_contains CHANGELOG.md "- Some change." "changelog entries should remain unchanged on version error"
finish_test
pass "test_invalid_release_version"
}
# -----------------------------------------------------------------------------
main() {
test_basic_flow
test_missing_unreleased
test_empty_changelog
test_snapshot_patch_increment
test_missing_readme_sentinel
test_invalid_release_version
echo "All tests passed."
}
main "$@"
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
WORKFLOW="$ROOT/.github/workflows/prepare-release.yml"
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle')"
fi
}
line_of() {
local needle="$1"
local line
line="$(grep -nF -- "$needle" "$WORKFLOW" | head -n1 | cut -d: -f1)"
if [[ -z "$line" ]]; then
fail "missing workflow line: '$needle'"
fi
printf '%s\n' "$line"
}
workflow_section() {
local start="$1"
local end="$2"
awk -v start="$start" -v end="$end" '
index($0, start) { active = 1 }
active { print }
index($0, end) { active = 0 }
' "$WORKFLOW"
}
test_issue_permissions_declared() {
echo "Running test_issue_permissions_declared"
local permissions
permissions="$(ruby -e 'require "yaml"; workflow = YAML.load_file(ARGV[0]); puts workflow.fetch("permissions").fetch("issues")' "$WORKFLOW")"
if [[ "$permissions" != "write" ]]; then
fail "prepare-release workflow should request issues: write permission"
fi
pass "test_issue_permissions_declared"
}
test_issue_sync_uses_targeted_search() {
echo "Running test_issue_sync_uses_targeted_search"
expect_file_contains "$WORKFLOW" "search.issuesAndPullRequests" "issue sync should use targeted issue search"
expect_file_contains "$WORKFLOW" "is:issue in:body" "issue sync should search by marker in the body"
if grep -Fq "issues.listForRepo" "$WORKFLOW"; then
fail "issue sync should not paginate every repo issue"
fi
pass "test_issue_sync_uses_targeted_search"
}
test_deprecation_scan_uses_java_scanner() {
echo "Running test_deprecation_scan_uses_java_scanner"
expect_file_contains "$WORKFLOW" "exec:java" "deprecation scan should run through Maven exec"
expect_file_contains "$WORKFLOW" "-pl ta4j-examples -am -DskipTests install" \
"deprecation scan should install reactor dependencies before execution"
expect_file_contains "$WORKFLOW" "-pl ta4j-examples exec:java" \
"deprecation scan should run exec:java only on ta4j-examples"
expect_file_contains "$WORKFLOW" "ta4jexamples.doc.RemovalReadyDeprecationScanner" \
"deprecation scan should invoke the Java scanner"
if grep -Fq "scan-removal-ready-deprecations.py" "$WORKFLOW"; then
fail "deprecation scan should not depend on the removed Python scanner"
fi
pass "test_deprecation_scan_uses_java_scanner"
}
test_prepare_release_sets_up_jdk25_before_maven() {
echo "Running test_prepare_release_sets_up_jdk25_before_maven"
local setup_line
local read_version_line
local gate_line
setup_line="$(line_of "Set up JDK 25")"
read_version_line="$(line_of "Read current version from POM")"
gate_line="$(line_of "Gate release-ready deprecations")"
if (( setup_line >= read_version_line )); then
fail "prepare-release should configure JDK 25 before reading the Maven project version"
fi
if (( setup_line >= gate_line )); then
fail "prepare-release should configure JDK 25 before the deprecation release gate"
fi
expect_file_contains "$WORKFLOW" "uses: actions/setup-java@v5" \
"prepare-release should use the standard setup-java action"
expect_file_contains "$WORKFLOW" "distribution: temurin" \
"prepare-release should use the same Temurin distribution as other workflows"
expect_file_contains "$WORKFLOW" "java-version: 25" \
"prepare-release should satisfy the Maven enforcer Java version"
expect_file_contains "$WORKFLOW" "cache: maven" \
"prepare-release should enable Maven cache for Java setup"
pass "test_prepare_release_sets_up_jdk25_before_maven"
}
test_release_gate_runs_before_next_snapshot() {
echo "Running test_release_gate_runs_before_next_snapshot"
local gate_line
local next_snapshot_line
gate_line="$(line_of "Gate release-ready deprecations")"
next_snapshot_line="$(line_of "Set Maven version to next snapshot")"
if (( gate_line >= next_snapshot_line )); then
fail "release deprecation gate should run before next snapshot version is applied"
fi
expect_file_contains "$WORKFLOW" "--target-removal-version \${RELEASE_VERSION}" \
"release gate should scan against the release version"
expect_file_contains "$WORKFLOW" "--include-overdue --fail-on-due" \
"release gate should include overdue removals and fail on due removals"
expect_file_contains "$WORKFLOW" "release-ready-deprecations-\${{ steps.versions.outputs.release }}" \
"release gate report should be uploaded as its own artifact"
pass "test_release_gate_runs_before_next_snapshot"
}
test_next_snapshot_issue_sync_runs_after_snapshot_commit() {
echo "Running test_next_snapshot_issue_sync_runs_after_snapshot_commit"
local snapshot_commit_line
local scan_line
local sync_line
local push_line
snapshot_commit_line="$(line_of "Commit next snapshot version")"
scan_line="$(line_of "Scan removal-ready deprecations")"
sync_line="$(line_of "Create or update removal-ready deprecation issues")"
push_line="$(line_of "Push release branch")"
if (( scan_line <= snapshot_commit_line )); then
fail "next-snapshot deprecation scan should run after the next snapshot commit"
fi
if (( sync_line <= scan_line )); then
fail "deprecation issue sync should run after the next-snapshot scan"
fi
if (( sync_line >= push_line )); then
fail "deprecation issue sync should finish before the release branch is pushed"
fi
expect_file_contains "$WORKFLOW" "NEXT_REMOVAL_VERSION=\"\${NEXT_VERSION%-SNAPSHOT}\"" \
"next-snapshot scan should derive the planned removal target from nextVersion"
expect_file_contains "$WORKFLOW" "--target-removal-version \${NEXT_REMOVAL_VERSION}" \
"next-snapshot scan should not rely on a dry-run POM mutation"
expect_file_contains "$WORKFLOW" "--target-removal-version \${NEXT_REMOVAL_VERSION} --include-overdue" \
"next-snapshot scan should include overdue removal versions when release versions jump"
pass "test_next_snapshot_issue_sync_runs_after_snapshot_commit"
}
test_release_audit_includes_deprecation_counts() {
echo "Running test_release_audit_includes_deprecation_counts"
expect_file_contains "$WORKFLOW" "\"removalIssuePlans\"" \
"release audit JSON should include removal issue plan counts"
expect_file_contains "$WORKFLOW" "\"removalIssuesCreated\"" \
"release audit JSON should include created removal issue counts"
expect_file_contains "$WORKFLOW" "\"removalIssuesClosedStale\"" \
"release audit JSON should include stale issue closure counts"
expect_file_contains "$WORKFLOW" "removal-ready deprecation issue plans" \
"workflow summary should include removal-ready deprecation plan counts"
expect_file_contains "$WORKFLOW" "prepare-release-audit-\${{ github.run_id }}" \
"prepare-release audit artifact should still be uploaded"
pass "test_release_audit_includes_deprecation_counts"
}
test_issue_sync_reconciles_stale_managed_issues() {
echo "Running test_issue_sync_reconciles_stale_managed_issues"
expect_file_contains "$WORKFLOW" "ta4j:deprecation-removal version=\${removalVersion}" \
"issue sync should search managed deprecation issues by removal version"
expect_file_contains "$WORKFLOW" "closedStaleCount" \
"issue sync should report stale managed issue closures"
expect_file_contains "$WORKFLOW" "state_reason: \"completed\"" \
"stale managed issues should be closed as completed"
expect_file_contains "$WORKFLOW" "closed_stale_count" \
"closed stale issue count should be exposed as an output"
pass "test_issue_sync_reconciles_stale_managed_issues"
}
test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues() {
echo "Running test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues"
local sync_section
sync_section="$(workflow_section "Create or update removal-ready deprecation issues" "Upload removal-ready deprecation report")"
if grep -Fq "labels:" <<<"$sync_section"; then
fail "generated deprecation cleanup issues should not receive labels"
fi
if grep -Fq "assignees:" <<<"$sync_section"; then
fail "generated deprecation cleanup issues should not receive assignees"
fi
pass "test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues"
}
test_deprecation_scans_run_during_dry_runs() {
echo "Running test_deprecation_scans_run_during_dry_runs"
local gate_section
local scan_section
local issue_section
gate_section="$(workflow_section "Gate release-ready deprecations" "Upload release-ready deprecation gate report")"
scan_section="$(workflow_section "Scan removal-ready deprecations" "Create or update removal-ready deprecation issues")"
issue_section="$(workflow_section "Create or update removal-ready deprecation issues" "Upload removal-ready deprecation report")"
if grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$gate_section"; then
fail "release deprecation gate should run during dry-run"
fi
if grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$scan_section"; then
fail "next-snapshot deprecation scan should run during dry-run"
fi
expect_file_contains "$WORKFLOW" "scan_status=\$scan_status" \
"release gate should expose scan status for delayed dry-run failure"
expect_file_contains "$WORKFLOW" "if: steps.dry_run.outputs.dryRun == 'true' && steps.deprecation_release_gate.outputs.scan_status != '0'" \
"dry-run should fail after report uploads when release-ready deprecations are found"
if ! grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$issue_section"; then
fail "deprecation issue sync should skip dry-run mutations"
fi
pass "test_deprecation_scans_run_during_dry_runs"
}
test_issue_permissions_declared
test_issue_sync_uses_targeted_search
test_deprecation_scan_uses_java_scanner
test_prepare_release_sets_up_jdk25_before_maven
test_release_gate_runs_before_next_snapshot
test_next_snapshot_issue_sync_runs_after_snapshot_commit
test_release_audit_includes_deprecation_counts
test_issue_sync_reconciles_stale_managed_issues
test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues
test_deprecation_scans_run_during_dry_runs
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
POM="$ROOT/pom.xml"
CORE_POM="$ROOT/ta4j-core/pom.xml"
EXAMPLES_POM="$ROOT/ta4j-examples/pom.xml"
WORKFLOW="$ROOT/.github/workflows/test.yml"
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle')"
fi
}
expect_file_matches() {
local file="$1"
local pattern="$2"
local msg="$3"
if ! grep -Eq -- "$pattern" "$file"; then
fail "$msg (pattern: '$pattern')"
fi
}
expect_file_not_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if grep -Fq -- "$needle" "$file"; then
fail "$msg (unexpected: '$needle')"
fi
}
expect_execution_contains() {
local file="$1"
local execution_id="$2"
local needle="$3"
local msg="$4"
local block
block="$(awk -v id="$execution_id" '
/<execution>/ { in_execution=1; block=$0 ORS; next }
in_execution { block = block $0 ORS }
/<\/execution>/ {
if (block ~ "<id>" id "</id>") {
print block
found=1
exit
}
in_execution=0
block=""
}
END {
if (!found) {
exit 1
}
}
' "$file")" || fail "$msg (execution not found: '$execution_id')"
if ! grep -Fq -- "$needle" <<<"$block"; then
fail "$msg (missing: '$needle' in execution '$execution_id')"
fi
}
test_parent_declares_advisory_quality_defaults() {
echo "Running test_parent_declares_advisory_quality_defaults"
expect_file_matches "$POM" "<spotbugs.version>[0-9]+(\\.[0-9]+)+</spotbugs.version>" "parent pom should pin SpotBugs with an explicit version"
expect_file_matches "$POM" "<jacoco.version>[0-9]+(\\.[0-9]+)+</jacoco.version>" "parent pom should pin JaCoCo with an explicit version"
expect_file_contains "$POM" "<ta4j.jacoco.line.minimum>0.80</ta4j.jacoco.line.minimum>" "line coverage threshold should be declared"
expect_file_contains "$POM" "<ta4j.jacoco.branch.minimum>0.80</ta4j.jacoco.branch.minimum>" "branch coverage threshold should be declared"
expect_file_not_contains "$POM" "<ta4j.spotbugs.failOnError>" "SpotBugs advisory mode should not rely on a shared property"
expect_file_not_contains "$POM" "<ta4j.jacoco.haltOnFailure>" "JaCoCo advisory mode should not rely on a shared property"
pass "test_parent_declares_advisory_quality_defaults"
}
test_parent_manages_quality_plugins_for_verify() {
echo "Running test_parent_manages_quality_plugins_for_verify"
expect_file_contains "$POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "parent pom should manage SpotBugs"
expect_file_contains "$POM" "<artifactId>jacoco-maven-plugin</artifactId>" "parent pom should manage JaCoCo"
expect_file_contains "$POM" "<artifactId>exec-maven-plugin</artifactId>" "parent pom should skip root exec:java runs"
expect_file_contains "$POM" "<skip>true</skip>" "parent pom should skip exec:java on the aggregator"
expect_file_contains "$POM" "<quiet>true</quiet>" "SpotBugs should stay compact in verify logs"
expect_file_contains "$POM" "@{argLine}" "Surefire should late-bind the JaCoCo agent argLine"
expect_execution_contains "$POM" "spotbugs-check" "<phase>verify</phase>" "SpotBugs should stay wired into verify"
expect_execution_contains "$POM" "spotbugs-check" "<failOnError>false</failOnError>" "SpotBugs should stay advisory only for the verify-bound execution"
expect_execution_contains "$POM" "jacoco-check" "<phase>verify</phase>" "JaCoCo should stay wired into verify"
expect_execution_contains "$POM" "jacoco-check" "<haltOnFailure>false</haltOnFailure>" "JaCoCo should stay advisory only for the verify-bound execution"
pass "test_parent_manages_quality_plugins_for_verify"
}
test_modules_opt_in_to_managed_quality_plugins() {
echo "Running test_modules_opt_in_to_managed_quality_plugins"
expect_file_contains "$CORE_POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "ta4j-core should opt into SpotBugs"
expect_file_contains "$CORE_POM" "<artifactId>jacoco-maven-plugin</artifactId>" "ta4j-core should opt into JaCoCo"
expect_file_contains "$EXAMPLES_POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "ta4j-examples should opt into SpotBugs"
expect_file_contains "$EXAMPLES_POM" "<artifactId>jacoco-maven-plugin</artifactId>" "ta4j-examples should opt into JaCoCo"
expect_file_contains "$EXAMPLES_POM" "<exec.mainClass>ta4jexamples.Quickstart</exec.mainClass>" "ta4j-examples should default exec:java to Quickstart"
expect_file_contains "$EXAMPLES_POM" "<mainClass>\${exec.mainClass}</mainClass>" "ta4j-examples should allow exec:java main-class overrides"
pass "test_modules_opt_in_to_managed_quality_plugins"
}
test_ci_runs_verify_for_both_jobs() {
echo "Running test_ci_runs_verify_for_both_jobs"
expect_file_contains "$WORKFLOW" "run: xvfb-run mvn -B verify" "default CI job should run verify"
expect_file_contains "$WORKFLOW" "run: xvfb-run mvn -B verify -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay" "non-demo CI job should run verify"
pass "test_ci_runs_verify_for_both_jobs"
}
test_docs_point_to_real_maven_commands() {
echo "Running test_docs_point_to_real_maven_commands"
expect_file_contains "$ROOT/README.md" "Run \`mvn verify\` before opening or updating a pull request." "README should point contributors at the real verify command"
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am spotbugs:check" "README should document the standalone SpotBugs loop"
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am test jacoco:report jacoco:check" "README should document the standalone JaCoCo gate"
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am -Dtest=BarSeriesManagerTest -Dsurefire.failIfNoSpecifiedTests=false test jacoco:report" "README should document a focused JaCoCo report-only loop"
expect_file_contains "$ROOT/README.md" "- [Build commands: Maven](#build-commands-maven)" "README table of contents should link to the renamed build section"
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.backtesting.TradingRecordParityBacktest" "README should demonstrate overriding exec:java with a non-default example"
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "**Run this before opening or updating a PR:** \`mvn -B verify\`" "contributing guide should use verify as the canonical PR command"
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -pl ta4j-core -am spotbugs:check" "contributing guide should document the standalone SpotBugs loop"
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -pl ta4j-core -am test jacoco:report jacoco:check" "contributing guide should document the standalone JaCoCo gate"
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -B license:format formatter:format" "contributing guide should keep the formatter and license fix command"
expect_file_not_contains "$ROOT/README.md" "./mvnw" "README should not advertise a missing Maven Wrapper"
pass "test_docs_point_to_real_maven_commands"
}
test_parent_declares_advisory_quality_defaults
test_parent_manages_quality_plugins_for_verify
test_modules_opt_in_to_managed_quality_plugins
test_ci_runs_verify_for_both_jobs
test_docs_point_to_real_maven_commands
echo
echo "All quality scan config tests passed."
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# test_release_helpers.sh
#
# Validates release workflow helper behavior used by GitHub Actions.
# =============================================================================
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/scripts/release/release_helpers.py"
PYTHON="${PYTHON:-python3}"
cleanup() {
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
return 0
}
trap cleanup EXIT
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
run_test() {
TMP="$(mktemp -d "${TMPDIR:-/tmp}/release-helpers.XXXXXX")"
pushd "$TMP" >/dev/null || exit 1
}
finish_test() {
popd >/dev/null || exit 1
rm -rf "$TMP"
}
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle')"
fi
}
expect_json_value() {
local file="$1"
local filter="$2"
local expected="$3"
local actual
actual="$($PYTHON - "$file" "$filter" <<'PY'
import json
import sys
path, key = sys.argv[1:3]
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
value = data
for part in key.split("."):
value = value[part]
if isinstance(value, bool):
print("true" if value else "false")
else:
print(value)
PY
)"
if [[ "$actual" != "$expected" ]]; then
fail "expected ${filter}=${expected}, got ${actual:-<missing>}"
fi
}
write_catalog_fixture() {
cat > catalog.json <<'EOF'
[
{
"id": "openai/gpt-4.1",
"name": "OpenAI GPT-4.1",
"publisher": "OpenAI",
"summary": "Large-context model",
"rate_limit_tier": "high",
"limits": {
"max_input_tokens": 1048576,
"max_output_tokens": 32768
},
"html_url": "https://github.com/marketplace/models/azure-openai/gpt-4-1"
}
]
EOF
}
test_catalog_preflight_accepts_configured_model() {
echo "Running test_catalog_preflight_accepts_configured_model"
run_test
write_catalog_fixture
GITHUB_OUTPUT=outputs.txt "$PYTHON" "$SCRIPT" catalog-preflight \
--model openai/gpt-4.1 \
--catalog-file catalog.json \
--output release-ai-model.json
expect_json_value release-ai-model.json id openai/gpt-4.1
expect_json_value release-ai-model.json max_input_tokens 1048576
expect_file_contains outputs.txt "model_id=openai/gpt-4.1" "catalog preflight should emit model id"
finish_test
pass "test_catalog_preflight_accepts_configured_model"
}
test_catalog_preflight_rejects_missing_model() {
echo "Running test_catalog_preflight_rejects_missing_model"
run_test
write_catalog_fixture
if "$PYTHON" "$SCRIPT" catalog-preflight \
--model openai/missing \
--catalog-file catalog.json \
--output release-ai-model.json >preflight.log 2>&1; then
fail "catalog preflight should reject an unavailable model"
fi
expect_file_contains preflight.log "openai/missing" "failure should name missing model"
finish_test
pass "test_catalog_preflight_rejects_missing_model"
}
test_parse_decision_normalizes_major_and_invalid_json() {
echo "Running test_parse_decision_normalizes_major_and_invalid_json"
run_test
cat > ai-content.txt <<'EOF'
```json
{"should_release":true,"bump":"major","confidence":0.91,"reason":"Breaking but pre-1.0 change","evidence":["public API changed"]}
```
EOF
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output decision.json --github-output outputs.txt
expect_json_value decision.json should_release true
expect_json_value decision.json bump minor
expect_file_contains outputs.txt "bump=minor" "major bumps should be downgraded in outputs"
cat > ai-content.txt <<'EOF'
{"should_release":"false","bump":"minor","confidence":0.7,"reason":"No release needed"}
EOF
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output string-false.json
expect_json_value string-false.json should_release false
expect_json_value string-false.json bump patch
cat > ai-content.txt <<'EOF'
{"should_release":"1","bump":"patch","confidence":0.7,"reason":"Release needed"}
EOF
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output string-true.json
expect_json_value string-true.json should_release true
cat > ai-content.txt <<'EOF'
{"should_release":"maybe","bump":"minor","confidence":0.7,"reason":"Ambiguous response"}
EOF
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output invalid-flag.json
expect_json_value invalid-flag.json should_release false
expect_json_value invalid-flag.json bump patch
expect_file_contains invalid-flag.json "invalid should_release 'maybe'" "invalid flag should be called out"
printf 'not-json' > ai-content.txt
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output invalid.json
expect_json_value invalid.json should_release false
expect_json_value invalid.json bump patch
expect_json_value invalid.json warning "Invalid AI JSON"
finish_test
pass "test_parse_decision_normalizes_major_and_invalid_json"
}
test_build_dossier_groups_and_truncates_diff() {
echo "Running test_build_dossier_groups_and_truncates_diff"
run_test
git init -q -b master
git config user.name "Test User"
git config user.email "test@example.com"
mkdir -p ta4j-core/src/main/java/org/ta4j/core scripts
cat > CHANGELOG.md <<'EOF'
## Unreleased
- Added release helper coverage.
EOF
cat > pom.xml <<'EOF'
<project><version>1.0.0-SNAPSHOT</version></project>
EOF
cat > ta4j-core/src/main/java/org/ta4j/core/Fixture.java <<'EOF'
package org.ta4j.core;
public class Fixture {
}
EOF
git add .
git commit -q -m "Initial"
git tag -a 1.0.0 -m "Release 1.0.0"
cat > ta4j-core/src/main/java/org/ta4j/core/Fixture.java <<'EOF'
package org.ta4j.core;
/**
* Fixture API.
*
* @since 1.0.1
*/
public class Fixture {
public String value() {
return "release-helper-dossier-with-enough-content-to-trigger-truncation";
}
}
EOF
git add .
git commit -q -m "Add fixture API"
"$PYTHON" "$SCRIPT" build-dossier \
--last-tag 1.0.0 \
--current-version 1.0.1-SNAPSHOT \
--pom-base 1.0.1 \
--max-diff-chars 120 \
--output release-dossier.md \
--audit-output release-audit.json
expect_file_contains release-dossier.md "production code" "dossier should group production code"
expect_file_contains release-dossier.md "Public API Signals" "dossier should include API signal section"
expect_file_contains release-dossier.md "[TRUNCATED" "dossier should make diff truncation explicit"
expect_json_value release-audit.json selected_diff_truncated true
finish_test
pass "test_build_dossier_groups_and_truncates_diff"
}
test_artifact_manifest_validates_expected_release_jars() {
echo "Running test_artifact_manifest_validates_expected_release_jars"
run_test
version=1.2.3
for file in \
"ta4j-core/target/ta4j-core-${version}.jar" \
"ta4j-core/target/ta4j-core-${version}-sources.jar" \
"ta4j-core/target/ta4j-core-${version}-javadoc.jar" \
"ta4j-core/target/ta4j-core-${version}-tests.jar" \
"ta4j-examples/target/ta4j-examples-${version}.jar" \
"ta4j-examples/target/ta4j-examples-${version}-sources.jar" \
"ta4j-examples/target/ta4j-examples-${version}-javadoc.jar"; do
mkdir -p "$(dirname "$file")"
: > "$file"
done
"$PYTHON" "$SCRIPT" artifact-manifest --version "$version" --output artifact-manifest.txt --strict
expect_file_contains artifact-manifest.txt "Missing release artifacts:" "manifest should include missing section"
expect_file_contains artifact-manifest.txt "- (none)" "manifest should report no missing artifacts"
: > "ta4j-core/target/unexpected.jar"
if "$PYTHON" "$SCRIPT" artifact-manifest --version "$version" --output artifact-manifest.txt --strict >manifest.log 2>&1; then
fail "strict artifact manifest should reject unexpected jars"
fi
expect_file_contains manifest.log "Unexpected target jars" "strict failure should name unexpected jars"
finish_test
pass "test_artifact_manifest_validates_expected_release_jars"
}
test_javadoc_warning_baseline_rejects_new_warnings() {
echo "Running test_javadoc_warning_baseline_rejects_new_warnings"
run_test
cat > baseline.txt <<'EOF'
ta4j-core/src/main/java/org/ta4j/core/Foo.java: warning: no @param for value
EOF
cat > release.log <<'EOF'
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-core/src/main/java/org/ta4j/core/Foo.java: warning: no @param for value
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-core/src/test/java/org/ta4j/core/BaseTradingRecordTest.java:[61,15] recordFill(org.ta4j.core.Trade) has been deprecated
EOF
"$PYTHON" "$SCRIPT" javadoc-warnings \
--baseline baseline.txt \
--output javadoc-warnings.txt \
--github-output outputs.txt \
--fail-on-new \
release.log
expect_file_contains outputs.txt "javadoc_warning_new_count=0" "baseline match should not report new warnings"
expect_file_contains javadoc-warnings.txt "current_count=1" "compiler warnings should not count as Javadoc warnings"
cat >> release.log <<'EOF'
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-examples/src/main/java/ta4jexamples/FooExample.java: warning: no @return
EOF
if "$PYTHON" "$SCRIPT" javadoc-warnings \
--baseline baseline.txt \
--output javadoc-warnings.txt \
--fail-on-new \
release.log >javadoc.log 2>&1; then
fail "new Javadoc warning should fail the baseline check"
fi
expect_file_contains javadoc.log "New Javadoc warning" "baseline failure should name new warning"
finish_test
pass "test_javadoc_warning_baseline_rejects_new_warnings"
}
test_catalog_preflight_accepts_configured_model
test_catalog_preflight_rejects_missing_model
test_parse_decision_normalizes_major_and_invalid_json
test_build_dossier_groups_and_truncates_diff
test_artifact_manifest_validates_expected_release_jars
test_javadoc_warning_baseline_rejects_new_warnings
echo
echo "All release helper tests passed."
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
WORKFLOWS="$ROOT/.github/workflows"
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle' in ${file#"$ROOT"/})"
fi
}
line_of() {
local file="$1"
local needle="$2"
local line
line="$(grep -nFm1 -- "$needle" "$file" || true)"
line="${line%%:*}"
if [[ -z "$line" ]]; then
fail "missing expected workflow line '$needle' in ${file#"$ROOT"/}"
fi
printf '%s\n' "$line"
}
input_section() {
local file="$1"
local input="$2"
awk -v needle=" ${input}:" '
index($0, needle) { active = 1 }
active { print }
active && index($0, "type: boolean") { exit }
' "$file"
}
workflow_section() {
local file="$1"
local start="$2"
local end="$3"
awk -v start="$start" -v end="$end" '
index($0, start) { active = 1 }
active { print }
active && index($0, end) { exit }
' "$file"
}
expect_section_contains() {
local section="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" <<<"$section"; then
fail "$msg (missing: '$needle')"
fi
}
assert_no_github_script_injected_binding_redeclarations() {
local workflow="$1"
awk -v file="${workflow#"$ROOT"/}" '
/uses: actions\/github-script@/ {
pending_script = 1
in_script = 0
next
}
pending_script && /^[[:space:]]*script: \|/ {
in_script = 1
next
}
in_script && /^[[:space:]]{6}- name:/ {
pending_script = 0
in_script = 0
}
in_script && /^[[:space:]]*(const|let|var)[[:space:]]+(core|github|context|glob|io|exec)[[:space:]]*=/ {
printf("[FAIL] %s redeclares actions/github-script injected binding at line %d: %s\n", file, NR, $0) > "/dev/stderr"
exit 1
}
' "$workflow"
}
test_maven_workflow_jobs_setup_jdk25_before_maven() {
echo "Running test_maven_workflow_jobs_setup_jdk25_before_maven"
local workflow
for workflow in "$WORKFLOWS"/*.yml; do
if ! grep -Eq '(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]' "$workflow"; then
continue
fi
awk -v file="${workflow#"$ROOT"/}" '
/^jobs:/ { in_jobs = 1; next }
in_jobs && /^ [A-Za-z0-9_-]+:$/ {
job = $1
sub(/:$/, "", job)
setup = 0
temurin = 0
jdk25 = 0
next
}
/^[^[:space:]]/ && $0 !~ /^jobs:/ {
in_jobs = 0
}
/uses: actions\/setup-java@v5/ {
setup = 1
temurin = 0
jdk25 = 0
}
setup && /distribution: temurin/ { temurin = 1 }
setup && /java-version: 25/ { jdk25 = 1 }
/^[[:space:]]*#/ { next }
/(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]/ {
if (!setup || !temurin || !jdk25) {
printf("[FAIL] %s job %s runs Maven before Temurin JDK 25 setup at line %d\n", file, job ? job : "(unknown)", NR) > "/dev/stderr"
exit 1
}
}
' "$workflow"
done
pass "test_maven_workflow_jobs_setup_jdk25_before_maven"
}
test_mutating_manual_workflows_default_to_dry_run() {
echo "Running test_mutating_manual_workflows_default_to_dry_run"
local workflow
for workflow in \
release-scheduler.yml \
prepare-release.yml \
publish-release.yml \
github-release.yml \
snapshot.yml \
release-health.yml; do
local file="$WORKFLOWS/$workflow"
local section
section="$(input_section "$file" dryRun)"
expect_section_contains "$section" "default: true" "${workflow} manual dryRun input should default true"
expect_section_contains "$section" "type: boolean" "${workflow} manual dryRun input should be typed as boolean"
done
pass "test_mutating_manual_workflows_default_to_dry_run"
}
test_official_triggers_normalize_to_non_dry_run() {
echo "Running test_official_triggers_normalize_to_non_dry_run"
expect_file_contains "$WORKFLOWS/release-scheduler.yml" 'if [ "$event" = "workflow_dispatch" ]; then' \
"release scheduler should branch normalization by event"
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "raw=\"\${DRY_RUN_INPUT:-true}\"" \
"release scheduler manual default should normalize to dry-run"
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "dry_run=false" \
"release scheduler scheduled runs should normalize to non-dry-run"
expect_file_contains "$WORKFLOWS/publish-release.yml" 'if [ "${{ github.event_name }}" = "pull_request" ]; then' \
"publish-release should normalize merged release PRs to non-dry-run"
expect_file_contains "$WORKFLOWS/github-release.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
"github-release should keep tag pushes non-dry-run"
expect_file_contains "$WORKFLOWS/snapshot.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
"snapshot should keep master pushes non-dry-run"
expect_file_contains "$WORKFLOWS/release-health.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
"release-health should keep schedule/push/workflow-run triggers non-dry-run"
pass "test_official_triggers_normalize_to_non_dry_run"
}
test_downstream_dispatches_explicitly_pass_dry_run() {
echo "Running test_downstream_dispatches_explicitly_pass_dry_run"
local scheduler_dispatch
scheduler_dispatch="$(workflow_section "$WORKFLOWS/release-scheduler.yml" 'workflow_id: "prepare-release.yml"' "dryRun")"
expect_section_contains "$scheduler_dispatch" "dryRun" \
"release scheduler dispatch should pass dryRun to prepare-release"
local prepare_dispatch
prepare_dispatch="$(workflow_section "$WORKFLOWS/prepare-release.yml" 'workflow_id: "publish-release.yml"' "dryRun")"
expect_section_contains "$prepare_dispatch" "dryRun" \
"prepare-release dispatch should pass dryRun to publish-release"
local publish_dispatch
publish_dispatch="$(workflow_section "$WORKFLOWS/publish-release.yml" 'workflow_id: "snapshot.yml"' 'dryRun: "false"')"
expect_section_contains "$publish_dispatch" 'dryRun: "false"' \
"publish-release should explicitly dispatch snapshot publication as non-dry-run"
pass "test_downstream_dispatches_explicitly_pass_dry_run"
}
test_mutating_steps_remain_dry_run_gated() {
echo "Running test_mutating_steps_remain_dry_run_gated"
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'" \
"release scheduler discussion mutation should skip dry-run and skipped scheduled runs"
expect_file_contains "$WORKFLOWS/prepare-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
"prepare-release mutations should be dry-run gated"
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update removal-ready deprecation issues" \
"prepare-release should still have managed issue sync"
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update release PR" \
"prepare-release should still have release PR mutation"
expect_file_contains "$WORKFLOWS/publish-release.yml" "Deploy Release to Maven Central" \
"publish-release should still have Maven Central deployment"
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
"publish-release mutations should be dry-run gated"
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
"publish-release discussion mutation should skip dry-run"
expect_file_contains "$WORKFLOWS/github-release.yml" "Create GitHub Release" \
"github-release should still publish GitHub releases"
expect_file_contains "$WORKFLOWS/github-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
"github-release publication should skip dry-run"
expect_file_contains "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" \
"snapshot workflow should still publish snapshots"
expect_file_contains "$WORKFLOWS/snapshot.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
"snapshot deployment and publishing-secret steps should skip dry-run"
expect_file_contains "$WORKFLOWS/release-health.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
"release-health discussion mutation should skip dry-run"
pass "test_mutating_steps_remain_dry_run_gated"
}
test_dry_run_summaries_and_audits_show_rerun_guidance() {
echo "Running test_dry_run_summaries_and_audits_show_rerun_guidance"
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "release-scheduler-mutation-plan.txt" \
"release scheduler audit artifact should include the dry-run mutation plan"
expect_file_contains "$WORKFLOWS/prepare-release.yml" '"mutationPlan"' \
"prepare-release audit JSON should include mutation plan"
expect_file_contains "$WORKFLOWS/publish-release.yml" '"mutationPlan"' \
"publish-release audit JSON should include mutation plan"
expect_file_contains "$WORKFLOWS/github-release.yml" '"mutationPlan"' \
"github-release audit JSON should include mutation plan"
expect_file_contains "$WORKFLOWS/snapshot.yml" '"snapshotVersion"' \
"snapshot audit JSON should include computed snapshot version"
expect_file_contains "$WORKFLOWS/snapshot.yml" '"mutationPlan"' \
"snapshot audit JSON should include mutation plan"
expect_file_contains "$WORKFLOWS/release-health.yml" '"mutationPlan"' \
"release-health audit JSON should include mutation plan"
expect_file_contains "$WORKFLOWS/prepare-release.yml" "rerun guidance" \
"prepare-release summary should include rerun guidance"
expect_file_contains "$WORKFLOWS/publish-release.yml" "rerun guidance" \
"publish-release summary should include rerun guidance"
expect_file_contains "$WORKFLOWS/snapshot.yml" "rerun guidance" \
"snapshot summary should include rerun guidance"
pass "test_dry_run_summaries_and_audits_show_rerun_guidance"
}
test_snapshot_and_health_manual_dry_runs_do_not_mutate() {
echo "Running test_snapshot_and_health_manual_dry_runs_do_not_mutate"
local deploy_section
deploy_section="$(workflow_section "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" "Snapshot publication summary")"
expect_section_contains "$deploy_section" "if: steps.dry_run.outputs.dryRun != 'true'" \
"snapshot deployment should skip dry-run"
local health_post_section
health_post_section="$(workflow_section "$WORKFLOWS/release-health.yml" "Post to Release Scheduler discussion" "with:")"
expect_section_contains "$health_post_section" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
"release-health discussion post should skip dry-run"
pass "test_snapshot_and_health_manual_dry_runs_do_not_mutate"
}
test_line_of_reports_missing_needles_cleanly() {
echo "Running test_line_of_reports_missing_needles_cleanly"
local tmp
local output
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
printf 'present\n' > "$tmp"
if output="$( (line_of "$tmp" "missing") 2>&1 )"; then
rm -f "$tmp"
fail "line_of should fail when the workflow line is missing"
fi
rm -f "$tmp"
expect_section_contains "$output" "missing expected workflow line 'missing'" \
"line_of should surface the explicit missing-line failure message"
pass "test_line_of_reports_missing_needles_cleanly"
}
test_publish_release_existing_tag_only_fails_real_runs() {
echo "Running test_publish_release_existing_tag_only_fails_real_runs"
expect_file_contains "$WORKFLOWS/publish-release.yml" \
"if: steps.check_tag.outputs.exists == 'true' && steps.dry_run.outputs.dryRun != 'true'" \
"publish-release should fail existing tags only during real runs"
expect_file_contains "$WORKFLOWS/publish-release.yml" "Report existing tag in dry-run mode" \
"publish-release should keep a dry-run audit path for existing tags"
expect_file_contains "$WORKFLOWS/publish-release.yml" "audit:existing_tag_dry_run" \
"publish-release dry-run tag detection should emit an audit line"
pass "test_publish_release_existing_tag_only_fails_real_runs"
}
test_github_release_preserves_workflow_support_checkout() {
echo "Running test_github_release_preserves_workflow_support_checkout"
local full_checkout_line
local support_checkout_line
local manifest_line
full_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout full history")"
support_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout workflow support files")"
manifest_line="$(line_of "$WORKFLOWS/github-release.yml" "workflow-support/scripts/release/release_helpers.py artifact-manifest")"
if (( support_checkout_line <= full_checkout_line )); then
fail "github-release should checkout workflow support after the release tag checkout"
fi
if (( manifest_line <= support_checkout_line )); then
fail "github-release should validate artifacts after workflow support checkout"
fi
expect_file_contains "$WORKFLOWS/github-release.yml" "path: workflow-support" \
"github-release should keep support helpers outside the release tag checkout"
pass "test_github_release_preserves_workflow_support_checkout"
}
test_github_script_blocks_do_not_redeclare_injected_bindings() {
echo "Running test_github_script_blocks_do_not_redeclare_injected_bindings"
local workflow
for workflow in "$WORKFLOWS"/*.yml; do
assert_no_github_script_injected_binding_redeclarations "$workflow"
done
pass "test_github_script_blocks_do_not_redeclare_injected_bindings"
}
test_github_script_binding_scan_rejects_bad_fixture() {
echo "Running test_github_script_binding_scan_rejects_bad_fixture"
local tmp
local output
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
cat > "$tmp" <<'YAML'
name: Bad fixture
jobs:
bad:
steps:
- name: Bad GitHub Script
uses: actions/github-script@v9
with:
script: |
const core = require("@actions/core");
YAML
if output="$(assert_no_github_script_injected_binding_redeclarations "$tmp" 2>&1)"; then
rm -f "$tmp"
fail "github-script injected binding scan should fail on a core redeclaration"
fi
rm -f "$tmp"
expect_section_contains "$output" "redeclares actions/github-script injected binding" \
"github-script injected binding scan should report the redeclaration"
pass "test_github_script_binding_scan_rejects_bad_fixture"
}
test_maven_workflow_jobs_setup_jdk25_before_maven
test_mutating_manual_workflows_default_to_dry_run
test_official_triggers_normalize_to_non_dry_run
test_downstream_dispatches_explicitly_pass_dry_run
test_mutating_steps_remain_dry_run_gated
test_dry_run_summaries_and_audits_show_rerun_guidance
test_snapshot_and_health_manual_dry_runs_do_not_mutate
test_line_of_reports_missing_needles_cleanly
test_publish_release_existing_tag_only_fails_real_runs
test_github_release_preserves_workflow_support_checkout
test_github_script_blocks_do_not_redeclare_injected_bindings
test_github_script_binding_scan_rejects_bad_fixture
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# test_resolve_release_tags.sh
#
# Validates release tag baseline selection and first-parent diagnostics.
# =============================================================================
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/scripts/resolve-release-tags.sh"
cleanup() {
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
return 0
}
trap cleanup EXIT
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_output_value() {
local output="$1"
local key="$2"
local expected="$3"
local actual=""
actual="$(printf '%s\n' "$output" | awk -F= -v key="$key" '$1 == key { print substr($0, length($1) + 2); exit }')"
if [[ "$actual" != "$expected" ]]; then
fail "expected ${key}=${expected}, got ${actual:-<missing>}"
fi
}
run_test() {
TMP="$(mktemp -d)"
mkdir -p "$TMP/scripts"
cp "$SCRIPT" "$TMP/scripts/resolve-release-tags.sh"
chmod +x "$TMP/scripts/resolve-release-tags.sh"
pushd "$TMP" >/dev/null || exit 1
git init -q -b master
git config user.name "Test User"
git config user.email "test@example.com"
}
finish_test() {
popd >/dev/null || exit 1
rm -rf "$TMP"
}
commit_file() {
local path="$1"
local content="$2"
local message="$3"
local date="$4"
mkdir -p "$(dirname "$path")"
printf '%s\n' "$content" > "$path"
git add "$path"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit -q -m "$message"
}
tag_release() {
local tag="$1"
local date="$2"
GIT_COMMITTER_DATE="$date" git tag -a "$tag" -m "Release $tag"
}
merge_branch() {
local branch="$1"
local message="$2"
local date="$3"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git merge --no-ff -m "$message" "$branch" >/dev/null
}
test_returns_none_when_no_release_tags_exist() {
echo "Running test_returns_none_when_no_release_tags_exist"
run_test
commit_file README.md "fixture" "Initial commit" "2024-01-01T00:00:00Z"
local out
out="$(scripts/resolve-release-tags.sh HEAD)"
expect_output_value "$out" "latest_tag" "none"
expect_output_value "$out" "last_reachable_tag" "none"
expect_output_value "$out" "last_first_parent_tag" "none"
expect_output_value "$out" "latest_tag_reachable" "n/a"
finish_test
pass "test_returns_none_when_no_release_tags_exist"
}
test_prefers_reachable_tag_for_release_baseline() {
echo "Running test_prefers_reachable_tag_for_release_baseline"
run_test
commit_file README.md "base" "Initial commit" "2024-01-01T00:00:00Z"
tag_release "0.21.0" "2024-01-02T00:00:00Z"
commit_file main.txt "after 0.21.0" "Master commit after 0.21.0" "2024-01-03T00:00:00Z"
git checkout -q -b release/0.22.4
commit_file release.txt "release payload" "Release 0.22.4" "2024-01-04T00:00:00Z"
tag_release "0.22.4" "2024-01-05T00:00:00Z"
git checkout -q master
merge_branch "release/0.22.4" "Merge release/0.22.4" "2024-01-06T00:00:00Z"
local out
out="$(scripts/resolve-release-tags.sh HEAD)"
expect_output_value "$out" "latest_tag" "0.22.4"
expect_output_value "$out" "last_reachable_tag" "0.22.4"
expect_output_value "$out" "last_first_parent_tag" "0.21.0"
expect_output_value "$out" "latest_tag_reachable" "true"
finish_test
pass "test_prefers_reachable_tag_for_release_baseline"
}
test_reports_unreachable_latest_tag() {
echo "Running test_reports_unreachable_latest_tag"
run_test
commit_file README.md "base" "Initial commit" "2024-02-01T00:00:00Z"
tag_release "0.22.3" "2024-02-02T00:00:00Z"
commit_file main.txt "master work" "Master commit" "2024-02-03T00:00:00Z"
git checkout -q -b release/0.22.4
commit_file release.txt "detached release" "Detached release commit" "2024-02-04T00:00:00Z"
tag_release "0.22.4" "2024-02-05T00:00:00Z"
git checkout -q master
local out
out="$(scripts/resolve-release-tags.sh HEAD)"
expect_output_value "$out" "latest_tag" "0.22.4"
expect_output_value "$out" "last_reachable_tag" "0.22.3"
expect_output_value "$out" "last_first_parent_tag" "0.22.3"
expect_output_value "$out" "latest_tag_reachable" "false"
finish_test
pass "test_reports_unreachable_latest_tag"
}
test_falls_back_to_v_prefixed_tags() {
echo "Running test_falls_back_to_v_prefixed_tags"
run_test
commit_file README.md "base" "Initial commit" "2024-03-01T00:00:00Z"
tag_release "v1.2.3" "2024-03-02T00:00:00Z"
local out
out="$(scripts/resolve-release-tags.sh HEAD)"
expect_output_value "$out" "latest_tag" "v1.2.3"
expect_output_value "$out" "last_reachable_tag" "v1.2.3"
expect_output_value "$out" "last_first_parent_tag" "v1.2.3"
expect_output_value "$out" "latest_tag_reachable" "true"
finish_test
pass "test_falls_back_to_v_prefixed_tags"
}
test_prefers_numeric_tags_over_v_prefixed_tags() {
echo "Running test_prefers_numeric_tags_over_v_prefixed_tags"
run_test
commit_file README.md "base" "Initial commit" "2024-04-01T00:00:00Z"
tag_release "1.2.3" "2024-04-02T00:00:00Z"
commit_file main.txt "master work" "Master commit" "2024-04-03T00:00:00Z"
tag_release "v9.9.9" "2024-04-04T00:00:00Z"
local out
out="$(scripts/resolve-release-tags.sh HEAD)"
expect_output_value "$out" "latest_tag" "1.2.3"
expect_output_value "$out" "last_reachable_tag" "1.2.3"
expect_output_value "$out" "last_first_parent_tag" "1.2.3"
expect_output_value "$out" "latest_tag_reachable" "true"
finish_test
pass "test_prefers_numeric_tags_over_v_prefixed_tags"
}
test_returns_head_values_when_target_ref_is_missing() {
echo "Running test_returns_head_values_when_target_ref_is_missing"
run_test
commit_file README.md "base" "Initial commit" "2024-05-01T00:00:00Z"
tag_release "0.30.0" "2024-05-02T00:00:00Z"
local out
out="$(scripts/resolve-release-tags.sh refs/remotes/origin/master)"
expect_output_value "$out" "latest_tag" "0.30.0"
expect_output_value "$out" "last_reachable_tag" "0.30.0"
expect_output_value "$out" "last_first_parent_tag" "0.30.0"
expect_output_value "$out" "latest_tag_reachable" "true"
finish_test
pass "test_returns_head_values_when_target_ref_is_missing"
}
test_returns_none_when_no_release_tags_exist
test_prefers_reachable_tag_for_release_baseline
test_reports_unreachable_latest_tag
test_falls_back_to_v_prefixed_tags
test_prefers_numeric_tags_over_v_prefixed_tags
test_returns_head_values_when_target_ref_is_missing
echo
echo "All resolve-release-tags tests passed."
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/scripts/release/release_helpers.py"
PYTHON="${PYTHON:-python3}"
cleanup() {
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
return 0
}
trap cleanup EXIT
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
new_temp_dir() {
mktemp -d "${TMPDIR:-/tmp}/snapshot-publication.XXXXXX"
}
expect_output_value() {
local file="$1"
local key="$2"
local expected="$3"
local actual=""
actual="$(awk -F= -v key="$key" '$1 == key { print substr($0, length($1) + 2); exit }' "$file")"
if [[ "$actual" != "$expected" ]]; then
fail "expected ${key}=${expected}, got ${actual:-<missing>}"
fi
}
expect_file_contains() {
local file="$1"
local expected="$2"
if ! grep -Fq -- "$expected" "$file"; then
fail "expected ${file} to contain: ${expected}"
fi
}
write_metadata_fixture() {
local path="$1"
local latest="$2"
local versions="$3"
local last_updated="$4"
cat > "$path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>org.ta4j</groupId>
<artifactId>ta4j-parent</artifactId>
<versioning>
<latest>${latest}</latest>
<versions>
${versions}
</versions>
<lastUpdated>${last_updated}</lastUpdated>
</versioning>
</metadata>
EOF
}
test_snapshot_version_present() {
echo "Running test_snapshot_version_present"
TMP="$(new_temp_dir)"
local metadata_file="$TMP/metadata.xml"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication.json"
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.6-SNAPSHOT</version>\n <version>0.22.7-SNAPSHOT</version>' "20260506001534"
"$PYTHON" "$SCRIPT" snapshot-publication \
--version "0.22.7-SNAPSHOT" \
--metadata-file "$metadata_file" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication" "true"
expect_output_value "$github_output" "snapshot_publication_latest" "0.22.7-SNAPSHOT"
expect_output_value "$github_output" "snapshot_publication_last_updated" "20260506001534"
rm -rf "$TMP"
pass "test_snapshot_version_present"
}
test_snapshot_version_missing() {
echo "Running test_snapshot_version_missing"
TMP="$(new_temp_dir)"
local metadata_file="$TMP/metadata.xml"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication.json"
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.6-SNAPSHOT</version>\n <version>0.22.7-SNAPSHOT</version>' "20260506001534"
"$PYTHON" "$SCRIPT" snapshot-publication \
--version "0.22.8-SNAPSHOT" \
--metadata-file "$metadata_file" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication" "false"
expect_output_value "$github_output" "snapshot_publication_latest" "0.22.7-SNAPSHOT"
rm -rf "$TMP"
pass "test_snapshot_version_missing"
}
test_non_snapshot_version_returns_na() {
echo "Running test_non_snapshot_version_returns_na"
TMP="$(new_temp_dir)"
local metadata_file="$TMP/metadata.xml"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication.json"
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.7-SNAPSHOT</version>' "20260506001534"
"$PYTHON" "$SCRIPT" snapshot-publication \
--version "0.22.7" \
--metadata-file "$metadata_file" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication" "n/a"
rm -rf "$TMP"
pass "test_non_snapshot_version_returns_na"
}
test_malformed_metadata_returns_unknown() {
echo "Running test_malformed_metadata_returns_unknown"
TMP="$(new_temp_dir)"
local metadata_file="$TMP/metadata.xml"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication.json"
printf '%s\n' '<metadata><versioning>' > "$metadata_file"
"$PYTHON" "$SCRIPT" snapshot-publication \
--version "0.22.7-SNAPSHOT" \
--metadata-file "$metadata_file" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication" "unknown"
rm -rf "$TMP"
pass "test_malformed_metadata_returns_unknown"
}
test_non_https_metadata_url_returns_unknown() {
echo "Running test_non_https_metadata_url_returns_unknown"
TMP="$(new_temp_dir)"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication.json"
"$PYTHON" "$SCRIPT" snapshot-publication \
--version "0.22.7-SNAPSHOT" \
--metadata-url "file:///tmp/snapshot-publication.xml" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication" "unknown"
expect_file_contains "$output_file" "\"error\": \"--metadata-url must use https\""
rm -rf "$TMP"
pass "test_non_https_metadata_url_returns_unknown"
}
test_snapshot_publication_policy_defers_push_runs() {
echo "Running test_snapshot_publication_policy_defers_push_runs"
TMP="$(new_temp_dir)"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication-policy.json"
"$PYTHON" "$SCRIPT" snapshot-publication-policy \
--event-name "push" \
--workflow-name "" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication_enforced" "false"
expect_output_value "$github_output" "snapshot_publication_pending_reason" "awaiting snapshot workflow completion before treating snapshot metadata drift as authoritative"
rm -rf "$TMP"
pass "test_snapshot_publication_policy_defers_push_runs"
}
test_snapshot_publication_policy_enforces_snapshot_workflow_runs() {
echo "Running test_snapshot_publication_policy_enforces_snapshot_workflow_runs"
TMP="$(new_temp_dir)"
local github_output="$TMP/github-output.txt"
local output_file="$TMP/snapshot-publication-policy.json"
"$PYTHON" "$SCRIPT" snapshot-publication-policy \
--event-name "workflow_run" \
--workflow-name "Publish Snapshot to Maven Central" \
--output "$output_file" \
--github-output "$github_output" >/dev/null
expect_output_value "$github_output" "snapshot_publication_enforced" "true"
expect_output_value "$github_output" "snapshot_publication_pending_reason" ""
rm -rf "$TMP"
pass "test_snapshot_publication_policy_enforces_snapshot_workflow_runs"
}
test_snapshot_version_present
test_snapshot_version_missing
test_non_snapshot_version_returns_na
test_malformed_metadata_returns_unknown
test_non_https_metadata_url_returns_unknown
test_snapshot_publication_policy_defers_push_runs
test_snapshot_publication_policy_enforces_snapshot_workflow_runs
echo
echo "All snapshot publication tests passed."
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# test_validate_central_metadata.sh
#
# Validates behavior of scripts/validate-central-metadata.sh
# =============================================================================
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/scripts/validate-central-metadata.sh"
cleanup() {
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
return 0
}
trap cleanup EXIT
fail() { echo "[FAIL] $1" >&2; exit 1; }
pass() { echo "[PASS] $1"; }
expect_contains() {
local haystack="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" <<<"$haystack"; then
fail "$msg (missing: '$needle')"
fi
}
expect_file_contains() {
local file="$1"
local needle="$2"
local msg="$3"
if ! grep -Fq -- "$needle" "$file"; then
fail "$msg (missing: '$needle')"
fi
}
create_fixture() {
local include_developers="$1"
mkdir -p ta4j-core ta4j-examples scripts
cp "$SCRIPT" scripts/validate-central-metadata.sh
chmod +x scripts/validate-central-metadata.sh
cat > pom.xml <<EOF
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.ta4j</groupId>
<artifactId>ta4j-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>Ta4j Parent</name>
<description>Fixture description</description>
<url>https://github.com/ta4j/ta4j</url>
<licenses>
<license>
<name>MIT License</name>
</license>
</licenses>
<scm>
<connection>scm:git:git://github.com/ta4j/ta4j.git</connection>
<url>https://github.com/ta4j/ta4j</url>
</scm>
EOF
if [[ "$include_developers" == "true" ]]; then
cat >> pom.xml <<'EOF'
<developers>
<developer>
<id>maintainer</id>
<name>Maintainer</name>
</developer>
</developers>
EOF
fi
cat >> pom.xml <<'EOF'
<modules>
<module>ta4j-core</module>
<module>ta4j-examples</module>
</modules>
</project>
EOF
for module in ta4j-core ta4j-examples; do
cat > "${module}/pom.xml" <<EOF
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.ta4j</groupId>
<artifactId>ta4j-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>${module}</artifactId>
</project>
EOF
done
}
run_test() {
TMP="$(mktemp -d)"
pushd "$TMP" >/dev/null || exit 1
}
finish_test() {
popd >/dev/null || exit 1
rm -rf "$TMP"
}
test_passes_with_complete_metadata() {
echo "Running test_passes_with_complete_metadata"
run_test
create_fixture "true"
local out
out="$(scripts/validate-central-metadata.sh)"
expect_contains "$out" "validation passed" "validator should pass for complete metadata"
finish_test
pass "test_passes_with_complete_metadata"
}
test_fails_when_developers_missing() {
echo "Running test_fails_when_developers_missing"
run_test
create_fixture "false"
local out_file="validator.log"
if scripts/validate-central-metadata.sh >"$out_file" 2>&1; then
fail "validator should fail when developers metadata is missing"
fi
expect_file_contains "$out_file" "developers[0].id" "should mention missing developer id"
expect_file_contains "$out_file" "developers[0].name" "should mention missing developer name"
expect_file_contains "$out_file" "validation failed" "should print summary failure"
finish_test
pass "test_fails_when_developers_missing"
}
test_passes_with_complete_metadata
test_fails_when_developers_missing
echo
echo "All validate-central-metadata tests passed."
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# validate-central-metadata.sh
#
# Validates Maven Central-required metadata before release publication.
# Uses Maven's evaluated effective model values (no XML parsing dependency).
#
# Usage:
# scripts/validate-central-metadata.sh
#
# =============================================================================
for tool in mvn; do
command -v "$tool" >/dev/null 2>&1 || {
echo "Error: required tool '$tool' not found" >&2
exit 1
}
done
declare -a ERRORS=()
add_error() {
local message="$1"
ERRORS+=("$message")
echo "::error::$message"
}
module_label() {
local module="$1"
if [[ "$module" == "ta4j-parent" ]]; then
echo "ta4j-parent (root)"
else
echo "$module"
fi
}
evaluate_expression() {
local module="$1"
local expression="$2"
local -a cmd=(mvn -q)
if [[ "$module" == "ta4j-parent" ]]; then
cmd+=(-N)
else
cmd+=(-pl "$module")
fi
cmd+=(help:evaluate "-Dexpression=${expression}" -DforceStdout)
local output
if ! output="$("${cmd[@]}" 2>&1)"; then
add_error "$(module_label "$module"): failed to evaluate '${expression}'"
return 1
fi
local value
value="$(printf '%s\n' "$output" | sed '/^[[:space:]]*$/d' | tail -n1 | tr -d '\r')"
printf '%s' "$value"
}
is_missing_value() {
local value="$1"
[[ -z "$value" || "$value" == "null object or invalid expression" ]]
}
check_required() {
local module="$1"
local expression="$2"
local field_name="$3"
local value
if ! value="$(evaluate_expression "$module" "$expression")"; then
return
fi
if is_missing_value "$value"; then
add_error "$(module_label "$module"): missing required '${field_name}' (${expression})"
fi
}
validate_module() {
local module="$1"
check_required "$module" "project.name" "name"
check_required "$module" "project.description" "description"
check_required "$module" "project.url" "url"
check_required "$module" "project.licenses[0].name" "licenses[0].name"
check_required "$module" "project.scm.connection" "scm.connection"
check_required "$module" "project.scm.url" "scm.url"
check_required "$module" "project.developers[0].id" "developers[0].id"
check_required "$module" "project.developers[0].name" "developers[0].name"
}
echo "Validating Maven Central metadata prerequisites..."
for module in ta4j-parent ta4j-core ta4j-examples; do
validate_module "$module"
done
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo "Maven Central metadata validation failed with ${#ERRORS[@]} issue(s)." >&2
exit 1
fi
echo "Maven Central metadata validation passed for ta4j-parent, ta4j-core, ta4j-examples."
+66
View File
@@ -0,0 +1,66 @@
# ta4j-core
`ta4j-core` contains the production API surface for strategy modeling, backtesting, analysis, and live-style record management.
## Start here
- Series model: `BarSeries`, `BaseBarSeries`, `ConcurrentBarSeries`
- Strategy model: `Indicator`, `Rule`, `Strategy`
- Execution model: `BarSeriesManager`, `BacktestExecutor`, `TradeExecutionModel`
- Trade/fill model: `TradingRecord`, `BaseTradingRecord`, `Trade`, `TradeFill`
- Analysis model: `AnalysisCriterion` and criteria packages
## Choose the right execution path
- Single strategy over one series: use `BarSeriesManager`
- Many candidates, tuning, weighted ranking: use `BacktestExecutor`
- Broker-confirmed/partial-fill replay: use manual evaluation loop + `BaseTradingRecord.operate(fill)`
## Live evaluation semantics (important)
- ta4j evaluates the bar state you provide at the requested index; it does not force closed-candle-only evaluation.
- If your feed uses `addBar(bar, true)` or equivalent replace-last-bar updates, you are evaluating a live (still-forming) candle.
- If you evaluate only after adding a completed bar, you are evaluating closed candles.
- For live execution, call `shouldEnter(index, tradingRecord)` / `shouldExit(index, tradingRecord)` and keep `tradingRecord` synchronized with broker-confirmed fills.
- Add an integration guard (for example, one entry per bar index) to avoid duplicate orders when a live candle keeps the same rule state across multiple updates.
## Trace rule decisions
- To answer "why did this fire?" or "why did this not fire?", enable SLF4J `TRACE` on the relevant `Rule` or `Strategy` logger and run the normal `isSatisfied(...)`, `shouldEnter(...)`, or `shouldExit(...)` call.
- TRACE logging is the off switch; there is no mutable trace mode to set on shared rule or strategy instances.
- Default trace output is `Rule.TraceMode.VERBOSE`, which emits the evaluated rule plus child-rule path/depth fields where a composite rule evaluates children.
- Use `Rule#isSatisfiedWithTraceMode(..., Rule.TraceMode.SUMMARY)` or `Strategy#shouldEnterWithTraceMode(...)` / `shouldExitWithTraceMode(...)` for a one-shot parent summary when child logs would be too noisy.
- Price and numeric comparison rules include the values they compared, the operator or window, and a short `reason` so a single rule trace line explains the decision.
- Stop rules include flat `key=value` decision fields such as `currentPrice`, `entryPrice`, `stopPrice`, `side`, trailing extremes, and configured amount or percentage fields.
## Choose the right series type
- Single-threaded backtests and deterministic local runs: `BaseBarSeries`
- Concurrent ingestion/evaluation pipelines: `ConcurrentBarSeries`
## Choose the right numeric model
- Precision-first workflows: `DecimalNum`
- Throughput-first workflows with accepted floating-point tradeoffs: `DoubleNum`
## Choose the right correlation metric
All rolling correlation indicators live under
`org.ta4j.core.indicators.statistics` and return `NaN` when the requested
window is not ready or the statistic is undefined.
| Question | Indicator | Notes |
| --- | --- | --- |
| Are two continuous signals linearly related in the same window? | `CorrelationCoefficientIndicator` | Pearson-style baseline for dense, simultaneous numeric series |
| Is the relationship monotonic but not necessarily linear? | `SpearmanRankCorrelationIndicator` | Uses average ranks for ties before applying Pearson correlation |
| Do ordered samples agree when ties matter? | `KendallTauIndicator` | Rolling Kendall tau-b with tie correction |
| Does one signal lead or trail another by a fixed number of bars? | `LaggedCorrelationIndicator` | Positive lag means the first indicator leads the second |
| Do two signals share non-linear structure? | `DistanceCorrelationIndicator` | Builds centered pairwise distance matrices; `O(window^2)` per calculated index |
| Does knowing one discretized state reduce uncertainty about another? | `MutualInformationIndicator` | Equal-width bins for v1; reports natural-log mutual information in nats |
| Does correlation only matter inside a trend, volatility, or custom state? | `RegimeSegmentedCorrelationIndicator` | Filters each rolling window with an `Indicator<Boolean>` regime selector |
## Companion user guides
- Backtesting: https://ta4j.github.io/ta4j-wiki/Backtesting.html
- Live trading: https://ta4j.github.io/ta4j-wiki/Live-trading.html
- Risk/criteria: https://ta4j.github.io/ta4j-wiki/Analysis-Criteria-and-Risk-Metrics.html
+139
View File
@@ -0,0 +1,139 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.ta4j</groupId>
<artifactId>ta4j-parent</artifactId>
<version>0.22.7-SNAPSHOT</version>
</parent>
<artifactId>ta4j-core</artifactId>
<name>Ta4j Core</name>
<description>ta4j is a Java library providing a simple API for technical analysis.</description>
<dependencies>
<!-- Logging facade -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Override parent: header/config live in parent dir (avoids CWD dependency when building from workspace root) -->
<plugin>
<groupId>com.mycila</groupId>
<artifactId>license-maven-plugin</artifactId>
<configuration>
<licenseSets>
<licenseSet>
<header>${project.parent.basedir}/license-header.txt</header>
<includes>
<include>**/*.java</include>
</includes>
</licenseSet>
</licenseSets>
</configuration>
</plugin>
<plugin>
<groupId>net.revelc.code.formatter</groupId>
<artifactId>formatter-maven-plugin</artifactId>
<configuration>
<configFile>${project.parent.basedir}/code-formatter.xml</configFile>
</configuration>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
<configuration>
<archive>
<manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>biz.aQute.bnd</groupId>
<artifactId>bnd-maven-plugin</artifactId>
<version>7.2.1</version>
<executions>
<execution>
<id>default-bnd-process</id>
<goals>
<goal>bnd-process</goal>
</goals>
</execution>
</executions>
<configuration>
<bnd>
<![CDATA[
-exportcontents: ${packages;NAMED;*org.ta4j.core*}
]]>
</bnd>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,37 @@
# AGENTS instructions for `org.ta4j.core`
Applies to this package unless a deeper `AGENTS.md` overrides it.
## API and implementation conventions
- Mirror existing `BarSeries` builder patterns. New series types should include a dedicated builder in the same package.
- Extend `BaseBarSeries` for new series implementations to reuse validation and index/removal behavior.
- Keep concurrent access safe: guard writes with `ReentrantReadWriteLock`, guard read-mostly paths with read locks, and return immutable snapshots from `getBarData()`.
- Add `@since <current-version-without-SNAPSHOT>` to each new public class or method in this scope.
- Treat Javadoc as part of the API: new public APIs and behavior changes must include clear intent/usage documentation.
## Coding style and model rules
- Use loggers (`LogManager` / `Logger`) instead of `System.out` / `System.err`.
- Prefer imports over fully qualified class names in implementation code.
- Prefer explicit local variable types. Use `var` only when the inferred type is immediately obvious and meaningfully reduces noise.
- When a valid reference `Num` or `BarSeries` already exists, derive the `NumFactory` from that reference instead of creating a parallel default factory.
- For numerical domain code, keep calculations in `Num` and derive constants from the active `NumFactory`; convert to primitive `double` only when the required operation is unavailable in `Num`, and document why at the call site.
- Do not keep generic null/NaN fallback helpers when the call site already guarantees a usable reference; encode the stronger invariant directly at the call site.
- For DTO/model carrier types, prefer immutable shapes: `record` first, then public final fields, then mutable getter/setter models only when required.
- For component metadata JSON, use helpers from `org.ta4j.core.serialization` (`ComponentDescriptor`, `ComponentSerialization`) instead of hand-rolled structures.
## Core surface-area gate (MUST)
- New top-level classes in `org.ta4j.core` are a last resort.
- Prefer package-private nested helpers or existing extension points before adding new top-level core types.
- Reuse existing shared APIs first (for example shared enums) rather than introducing parallel concepts.
- If adding a new top-level core type is unavoidable, include a short rationale in the active PRD/checklist or PR notes.
## Scoped guides
- Read deeper package guides before editing these areas:
- `indicators/AGENTS.md`
- `serialization/AGENTS.md`
- `strategy/named/AGENTS.md`
- `bars/AGENTS.md`
@@ -0,0 +1,407 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.AnalysisContext;
import org.ta4j.core.analysis.AnalysisWindow;
import org.ta4j.core.analysis.AnalysisContext.MissingHistoryPolicy;
import org.ta4j.core.analysis.AnalysisContext.PositionInclusionPolicy;
import org.ta4j.core.analysis.OpenPositionHandling;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.num.Num;
/**
* An analysis criterion. It can be used to:
*
* <ul>
* <li>analyze the performance of a {@link Strategy strategy}
* <li>compare several {@link Strategy strategies} together
* </ul>
*/
public interface AnalysisCriterion {
/** Filter to differentiate between winning or losing positions. */
enum PositionFilter {
/** Consider only winning positions. */
PROFIT,
/** Consider only losing positions. */
LOSS;
}
/**
* @param series the bar series, not null
* @param position the position, not null
* @return the criterion value for the position
*/
Num calculate(BarSeries series, Position position);
/**
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @return the criterion value for the positions
*/
Num calculate(BarSeries series, TradingRecord tradingRecord);
/**
* Calculates this criterion over a specific analysis window using
* {@link AnalysisContext#defaults() default context options}.
*
* <p>
* Examples:
* </p>
* <ul>
* <li>Past 7 days:
* {@code criterion.calculate(series, record, AnalysisWindow.lookbackDuration(Duration.ofDays(7)))}</li>
* <li>Past 30 days:
* {@code criterion.calculate(series, record, AnalysisWindow.lookbackDuration(Duration.ofDays(30)))}</li>
* <li>Explicit date range:
* {@code criterion.calculate(series, record, AnalysisWindow.timeRange(Instant.parse("2026-02-10T00:00:00Z"), Instant.parse("2026-02-14T00:00:00Z")))}</li>
* </ul>
*
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @param window the requested analysis window, not null
* @return the criterion value for the window
* @since 0.22.4
*/
default Num calculate(BarSeries series, TradingRecord tradingRecord, AnalysisWindow window) {
return calculate(series, tradingRecord, window, AnalysisContext.defaults());
}
/**
* Calculates this criterion over a specific analysis window.
*
* <p>
* Window boundaries follow:
* </p>
* <ul>
* <li>bar indices: start inclusive, end inclusive</li>
* <li>time windows: start inclusive, end exclusive (bar membership is based on
* bar end time)</li>
* </ul>
*
* <p>
* On constrained or moving series (for example when
* {@link BarSeries#setMaximumBarCount(int)} removed historical bars), missing
* history is handled according to
* {@link AnalysisContext#missingHistoryPolicy()}:
* </p>
* <ul>
* <li>{@link AnalysisContext.MissingHistoryPolicy#STRICT}: fails when requested
* history is unavailable</li>
* <li>{@link AnalysisContext.MissingHistoryPolicy#CLAMP}: intersects requested
* range with available logical indices</li>
* </ul>
*
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @param window the requested analysis window, not null
* @param context window resolution and projection options, not null
* @return the criterion value for the window
* @since 0.22.4
*/
default Num calculate(BarSeries series, TradingRecord tradingRecord, AnalysisWindow window,
AnalysisContext context) {
Objects.requireNonNull(series, "series");
Objects.requireNonNull(tradingRecord, "tradingRecord");
Objects.requireNonNull(window, "window");
Objects.requireNonNull(context, "context");
if (series.isEmpty()) {
return calculate(series, tradingRecord);
}
int[] resolvedWindow = resolveWindow(series, window, context);
int windowStartIndex = resolvedWindow[0];
int windowEndIndex = resolvedWindow[1];
boolean hasBars = resolvedWindow[2] == 1;
TradingRecord projectedRecord = projectTradingRecord(series, tradingRecord, windowStartIndex, windowEndIndex,
hasBars, context);
return calculate(series, projectedRecord);
}
private static int[] resolveWindow(BarSeries series, AnalysisWindow window, AnalysisContext context) {
int availableStart = series.getBeginIndex();
int availableEnd = series.getEndIndex();
int requestedStart;
int requestedEnd;
boolean requestedEmpty;
boolean lowerBoundBeforeAvailable;
boolean upperBoundAfterAvailable;
switch (window) {
case AnalysisWindow.BarRange barRange:
requestedStart = barRange.startIndexInclusive();
requestedEnd = barRange.endIndexInclusive();
requestedEmpty = false;
lowerBoundBeforeAvailable = requestedStart < availableStart;
upperBoundAfterAvailable = requestedEnd > availableEnd;
break;
case AnalysisWindow.LookbackBars lookbackBars:
Instant lookbackBarsAsOf = context.asOf();
Instant availableStartTimeForBars = series.getBar(availableStart).getEndTime();
Instant availableEndExclusiveForBars = series.getBar(availableEnd).getEndTime().plusNanos(1);
lowerBoundBeforeAvailable = lookbackBarsAsOf != null
&& lookbackBarsAsOf.isBefore(availableStartTimeForBars);
upperBoundAfterAvailable = lookbackBarsAsOf != null
&& lookbackBarsAsOf.isAfter(availableEndExclusiveForBars);
int lookbackBarsAnchor = lookbackBarsAsOf == null ? availableEnd
: findLastIndexAtOrBefore(series, lookbackBarsAsOf, availableStart, availableEnd);
if (lookbackBarsAnchor < 0) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
requestedEmpty = true;
lowerBoundBeforeAvailable = true;
break;
}
requestedStart = lookbackBarsAnchor - lookbackBars.barCount() + 1;
requestedEnd = lookbackBarsAnchor;
requestedEmpty = false;
lowerBoundBeforeAvailable = lowerBoundBeforeAvailable || requestedStart < availableStart;
upperBoundAfterAvailable = upperBoundAfterAvailable || requestedEnd > availableEnd;
break;
case AnalysisWindow.TimeRange timeRange:
requestedStart = findFirstIndexAtOrAfter(series, timeRange.startInclusive(), availableStart, availableEnd);
requestedEnd = findLastIndexBefore(series, timeRange.endExclusive(), availableStart, availableEnd);
requestedEmpty = requestedStart < 0 || requestedEnd < 0 || requestedStart > requestedEnd;
lowerBoundBeforeAvailable = timeRange.startInclusive().isBefore(series.getBar(availableStart).getEndTime());
upperBoundAfterAvailable = timeRange.endExclusive()
.isAfter(series.getBar(availableEnd).getEndTime().plusNanos(1));
if (requestedEmpty) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
}
break;
case AnalysisWindow.LookbackDuration lookbackDuration:
Instant endExclusive = context.asOf() != null ? context.asOf()
: series.getBar(availableEnd).getEndTime().plusNanos(1);
Instant startInclusive = endExclusive.minus(lookbackDuration.duration());
requestedStart = findFirstIndexAtOrAfter(series, startInclusive, availableStart, availableEnd);
requestedEnd = findLastIndexBefore(series, endExclusive, availableStart, availableEnd);
requestedEmpty = requestedStart < 0 || requestedEnd < 0 || requestedStart > requestedEnd;
lowerBoundBeforeAvailable = startInclusive.isBefore(series.getBar(availableStart).getEndTime());
upperBoundAfterAvailable = endExclusive.isAfter(series.getBar(availableEnd).getEndTime().plusNanos(1));
if (requestedEmpty) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
}
break;
}
if (context.missingHistoryPolicy() == MissingHistoryPolicy.STRICT
&& (lowerBoundBeforeAvailable || upperBoundAfterAvailable)) {
throw unavailableHistoryException(requestedStart, requestedEnd, availableStart, availableEnd);
}
int resolvedStart = requestedStart;
int resolvedEnd = requestedEnd;
if (context.missingHistoryPolicy() == MissingHistoryPolicy.CLAMP) {
resolvedStart = Math.max(resolvedStart, availableStart);
resolvedEnd = Math.min(resolvedEnd, availableEnd);
}
if (context.missingHistoryPolicy() == MissingHistoryPolicy.STRICT
&& (resolvedStart < availableStart || resolvedEnd > availableEnd)) {
throw unavailableHistoryException(requestedStart, requestedEnd, availableStart, availableEnd);
}
if (requestedEmpty || resolvedStart > resolvedEnd) {
int anchor = Math.min(Math.max(resolvedStart, availableStart), availableEnd);
return new int[] { anchor, anchor, 0 };
}
return new int[] { resolvedStart, resolvedEnd, 1 };
}
private static IllegalArgumentException unavailableHistoryException(int requestedStart, int requestedEnd,
int availableStart, int availableEnd) {
String message = String.format("Requested window [%d, %d] is outside available series range [%d, %d]",
requestedStart, requestedEnd, availableStart, availableEnd);
return new IllegalArgumentException(message);
}
private static int findLastIndexAtOrBefore(BarSeries series, Instant asOf, int availableStart, int availableEnd) {
for (int i = availableEnd; i >= availableStart; i--) {
if (!series.getBar(i).getEndTime().isAfter(asOf)) {
return i;
}
}
return -1;
}
private static int findFirstIndexAtOrAfter(BarSeries series, Instant startInclusive, int availableStart,
int availableEnd) {
for (int i = availableStart; i <= availableEnd; i++) {
if (!series.getBar(i).getEndTime().isBefore(startInclusive)) {
return i;
}
}
return -1;
}
private static int findLastIndexBefore(BarSeries series, Instant endExclusive, int availableStart,
int availableEnd) {
for (int i = availableEnd; i >= availableStart; i--) {
if (series.getBar(i).getEndTime().isBefore(endExclusive)) {
return i;
}
}
return -1;
}
private static TradingRecord projectTradingRecord(BarSeries series, TradingRecord source, int start, int end,
boolean hasBars, AnalysisContext context) {
CostModel transactionCostModel = Objects.requireNonNullElseGet(source.getTransactionCostModel(),
ZeroCostModel::new);
CostModel holdingCostModel = Objects.requireNonNullElseGet(source.getHoldingCostModel(), ZeroCostModel::new);
BaseTradingRecord projectedRecord = new BaseTradingRecord(source.getStartingType(), start, end,
transactionCostModel, holdingCostModel);
if (!hasBars) {
return projectedRecord;
}
PositionInclusionPolicy inclusionPolicy = context.positionInclusionPolicy();
List<Position> includedPositions = new ArrayList<>();
for (Position position : source.getPositions()) {
if (includeClosedPosition(position, start, end, inclusionPolicy)) {
includedPositions.add(position);
}
}
if (context.openPositionHandling() == OpenPositionHandling.MARK_TO_MARKET) {
List<Position> openPositions = openPositionsForMarkToMarket(source, end, transactionCostModel,
holdingCostModel);
for (Position openPosition : openPositions) {
Position syntheticPosition = createMarkToMarketPosition(series, openPosition, end, holdingCostModel);
if (syntheticPosition != null
&& includeClosedPosition(syntheticPosition, start, end, inclusionPolicy)) {
includedPositions.add(syntheticPosition);
}
}
}
includedPositions.sort(Comparator.comparingInt(position -> position.getExit().getIndex()));
for (Position position : includedPositions) {
Trade entry = position.getEntry();
Trade exit = position.getExit();
projectedRecord.operate(entry);
projectedRecord.operate(exit);
}
return projectedRecord;
}
private static Position createMarkToMarketPosition(BarSeries series, Position currentPosition, int windowEndIndex,
CostModel holdingCostModel) {
if (currentPosition == null || !currentPosition.isOpened()) {
return null;
}
Trade entryTrade = currentPosition.getEntry();
if (entryTrade == null || entryTrade.getIndex() > windowEndIndex) {
return null;
}
Num amount = entryTrade.getAmount();
Num closePrice = series.getBar(windowEndIndex).getClosePrice();
CostModel transactionCostModel = entryTrade.getCostModel();
Trade syntheticExit = entryTrade.isBuy()
? Trade.sellAt(windowEndIndex, closePrice, amount, transactionCostModel)
: Trade.buyAt(windowEndIndex, closePrice, amount, transactionCostModel);
return new Position(entryTrade, syntheticExit, transactionCostModel, holdingCostModel);
}
private static List<Position> openPositionsForMarkToMarket(TradingRecord source, int windowEndIndex,
CostModel transactionCostModel, CostModel holdingCostModel) {
List<Position> openPositions = source.getOpenPositions();
if (!openPositions.isEmpty()) {
return openPositionsWithinWindow(openPositions, windowEndIndex);
}
Position currentPosition = source.getCurrentPosition();
if (currentPosition == null || !currentPosition.isOpened()) {
return List.of();
}
return List.of(currentPosition);
}
private static List<Position> openPositionsWithinWindow(List<Position> openPositions, int windowEndIndex) {
List<Position> positions = new ArrayList<>();
for (Position openPosition : openPositions) {
if (openPosition == null || !openPosition.isOpened()) {
continue;
}
if (openPosition.getEntry().getIndex() > windowEndIndex) {
continue;
}
positions.add(openPosition);
}
return positions;
}
private static boolean includeClosedPosition(Position position, int start, int end,
PositionInclusionPolicy positionInclusionPolicy) {
if (position == null || !position.isClosed()) {
return false;
}
int entry = position.getEntry().getIndex();
int exit = position.getExit().getIndex();
return switch (positionInclusionPolicy) {
case EXIT_IN_WINDOW -> exit >= start && exit <= end;
case FULLY_CONTAINED -> entry >= start && exit <= end;
};
}
/**
* @param manager the bar series manager with entry type of BUY
* @param strategies a list of strategies
* @return the best strategy (among the provided ones) according to the
* criterion
*/
default Strategy chooseBest(BarSeriesManager manager, List<Strategy> strategies) {
return chooseBest(manager, TradeType.BUY, strategies);
}
/**
* @param manager the bar series manager
* @param tradeType the entry type (BUY or SELL) of the first trade in the
* trading session
* @param strategies a list of strategies
* @return the best strategy (among the provided ones) according to the
* criterion
*/
default Strategy chooseBest(BarSeriesManager manager, TradeType tradeType, List<Strategy> strategies) {
Strategy bestStrategy = strategies.getFirst();
Num bestCriterionValue = calculate(manager.getBarSeries(), manager.run(bestStrategy));
for (int i = 1; i < strategies.size(); i++) {
Strategy currentStrategy = strategies.get(i);
Num currentCriterionValue = calculate(manager.getBarSeries(), manager.run(currentStrategy, tradeType));
if (betterThan(currentCriterionValue, bestCriterionValue)) {
bestStrategy = currentStrategy;
bestCriterionValue = currentCriterionValue;
}
}
return bestStrategy;
}
/**
* @param criterionValue1 the first value
* @param criterionValue2 the second value
* @return true if the first value is better than (according to the criterion)
* the second one, false otherwise
*/
boolean betterThan(Num criterionValue1, Num criterionValue2);
}
@@ -0,0 +1,240 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.Function;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A {@code Bar} is aggregated open/high/low/close/volume/etc. data over a time
* period. It represents the "end bar" of a time period.
*/
public interface Bar extends Serializable {
/**
* @return the time period of the bar
*/
Duration getTimePeriod();
/**
* @return the begin timestamp of the bar period (in UTC).
*/
Instant getBeginTime();
/**
* @return the end timestamp of the bar period (in UTC).
*/
Instant getEndTime();
/**
* @return the open price of the bar period
*/
Num getOpenPrice();
/**
* @return the high price of the bar period
*/
Num getHighPrice();
/**
* @return the low price of the bar period
*/
Num getLowPrice();
/**
* @return the close price of the bar period
*/
Num getClosePrice();
/**
* @return the total traded volume of the bar period
*/
Num getVolume();
/**
* @return the total traded amount (tradePrice x tradeVolume) of the bar period
*/
Num getAmount();
/**
* @return the number of trades of the bar period
*/
long getTrades();
/**
* @param timestamp a timestamp
* @return true if the provided timestamp is between the begin time and the end
* time of the current period, false otherwise
*/
default boolean inPeriod(Instant timestamp) {
return timestamp != null && !timestamp.isBefore(getBeginTime()) && timestamp.isBefore(getEndTime());
}
/**
* @return the bar's begin time in UTC as {@link ZonedDateTime}
*/
default ZonedDateTime getZonedBeginTime() {
return getBeginTime().atZone(ZoneOffset.UTC);
}
/**
* @return the bar's end time in UTC as {@link ZonedDateTime}
*/
default ZonedDateTime getZonedEndTime() {
return getEndTime().atZone(ZoneOffset.UTC);
}
/**
* Converts the begin time of the bar to a time in the system's time zone.
*
* <p>
* <b>Warning:</b> The use of {@link ZoneId#systemDefault()} may introduce
* variability based on the system's default time zone settings. This can result
* in inconsistencies in time calculations and comparisons, particularly due to
* daylight saving time (DST). It is recommended to always utilize either
* {@link #getBeginTime()} or {@link #getZonedBeginTime()} for accurate results.
*
* @return the bar's begin time converted to system time zone
*/
default ZonedDateTime getSystemZonedBeginTime() {
return getBeginTime().atZone(ZoneId.systemDefault());
}
/**
* Converts the end time of the bar to a time in the system's time zone.
*
* <p>
* <b>Warning:</b> The use of {@link ZoneId#systemDefault()} may introduce
* variability based on the system's default time zone settings. This can result
* in inconsistencies in time calculations and comparisons, particularly due to
* daylight saving time (DST). It is recommended to always utilize either
* {@link #getEndTime()} or {@link #getZonedEndTime()} for accurate results.
*
* @return the bar's end time converted to system time zone
*/
default ZonedDateTime getSystemZonedEndTime() {
return getEndTime().atZone(ZoneId.systemDefault());
}
/**
* @return a user-friendly representation of the end timestamp in the system's
* time zone
*/
default String getDateName() {
return getSystemZonedEndTime().format(DateTimeFormatter.ISO_DATE_TIME);
}
/**
* @return an even more user-friendly representation of the end timestamp in the
* system's time zone
*/
default String getSimpleDateName() {
return getSystemZonedEndTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
/**
* @return true if this is a bearish bar, false otherwise
*/
default boolean isBearish() {
Num openPrice = getOpenPrice();
Num closePrice = getClosePrice();
return (openPrice != null) && (closePrice != null) && closePrice.isLessThan(openPrice);
}
/**
* @return true if this is a bullish bar, false otherwise
*/
default boolean isBullish() {
Num openPrice = getOpenPrice();
Num closePrice = getClosePrice();
return (openPrice != null) && (closePrice != null) && openPrice.isLessThan(closePrice);
}
/**
* Adds a trade and updates the close price at the end of the bar period.
*
* @param tradeVolume the traded volume
* @param tradePrice the actual price per asset
*/
void addTrade(Num tradeVolume, Num tradePrice);
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
* @param numFunction the numbers precision
*/
default void addPrice(String price, Function<Number, Num> numFunction) {
addPrice(numFunction.apply(new BigDecimal(price)));
}
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
* @param numFunction the numbers precision
*/
default void addPrice(Number price, Function<Number, Num> numFunction) {
addPrice(numFunction.apply(price));
}
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
*/
void addPrice(Num price);
/**
* Returns the {@link NumFactory} associated with the first available price
* field on the bar.
*
* @return the {@link NumFactory} derived from the bar's numeric values
* @throws IllegalArgumentException if no price fields are available to
* determine a factory
*
* @since 0.22.1
*/
default NumFactory numFactory() {
var open = getOpenPrice();
if (open != null) {
return open.getNumFactory();
}
var close = getClosePrice();
if (close != null) {
return close.getNumFactory();
}
var high = getHighPrice();
if (high != null) {
return high.getNumFactory();
}
var low = getLowPrice();
if (low != null) {
return low.getNumFactory();
}
var volume = getVolume();
if (volume != null) {
return volume.getNumFactory();
}
var amount = getAmount();
if (amount != null) {
return amount.getNumFactory();
}
throw new IllegalArgumentException("Cannot select a NumFactory: no price fields are available on the bar.");
}
}
@@ -0,0 +1,211 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import org.ta4j.core.num.Num;
/**
* Builder for one OHLCV bar.
*
* <p>
* Typical usage in backtests is to set period/time plus OHLCV fields and call
* {@link #add()}. For real-time trade ingestion, prefer series-level ingestion
* APIs such as
* {@link ConcurrentBarSeries#ingestTrade(java.time.Instant, Number, Number)} so
* rollover logic stays consistent.
* </p>
*/
public interface BarBuilder {
/**
* @param timePeriod the time period (optional if {@link #beginTime(Instant)}
* and {@link #endTime(Instant)} are given)
* @return {@code this}
*/
BarBuilder timePeriod(Duration timePeriod);
/**
* @param beginTime the begin time of the bar period (optional if
* {@link #endTime(Instant)} is given)
* @return {@code this}
*/
BarBuilder beginTime(Instant beginTime);
/**
* @param endTime the end time of the bar period (optional if
* {@link #beginTime(Instant)} is given)
* @return {@code this}
*/
BarBuilder endTime(Instant endTime);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(Num openPrice);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(Number openPrice);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(String openPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(Number highPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(String highPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(Num highPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(Num lowPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(Number lowPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(String lowPrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(Num closePrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(Number closePrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(String closePrice);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(Num volume);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(Number volume);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(String volume);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(Num amount);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(Number amount);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(String amount);
/**
* @param trades the number of trades of the bar period
* @return {@code this}
*/
BarBuilder trades(long trades);
/**
* @param trades the number of trades of the bar period
* @return {@code this}
*/
BarBuilder trades(String trades);
/**
* Updates the builder with a trade event and adds or updates bars as needed.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
default void addTrade(Instant time, Num tradeVolume, Num tradePrice) {
throw new UnsupportedOperationException("Trade ingestion not supported by " + getClass().getSimpleName());
}
/**
* Updates the builder with a trade event and adds or updates bars as needed.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
default void addTrade(Instant time, Num tradeVolume, Num tradePrice, RealtimeBar.Side side,
RealtimeBar.Liquidity liquidity) {
addTrade(time, tradeVolume, tradePrice);
}
/**
* @param barSeries the series used for bar addition
* @return {@code this}
*/
BarBuilder bindTo(BarSeries barSeries);
/**
* @return bar created from obtained data
*/
Bar build();
/**
* Builds bar with {@link #build()} and adds it to series
*/
void add();
}
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
/**
* A factory that provides a builder for a bar.
*
* <p>
* Pair the factory with the bar semantics you need: time bars for clock-aligned
* candles, or alternative factories for volume/amount/tick driven aggregation.
* In most workflows, this is configured once at series construction time.
* </p>
*/
public interface BarBuilderFactory extends Serializable {
/**
* Constructor.
*
* @param series the bar series to which the created bar should be added
* @return the bar builder
*/
BarBuilder createBarBuilder(BarSeries series);
}
@@ -0,0 +1,263 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A {@code BarSeries} is a sequence of {@link Bar bars} separated by a
* predefined period (e.g. 15 minutes, 1 day, etc.).
*
* Notably, it can be:
*
* <ul>
* <li>the base of {@link Indicator indicator} calculations
* <li>constrained between beginning and ending indices (e.g. for some
* backtesting cases)
* <li>limited to a fixed number of bars (e.g. for actual trading)
* </ul>
*
* <p>
* The bar series is the core underlying dataset in ta4j. It represents a
* timeline of financial data (OHLCV) and acts as the source of truth for
* indicators, backtesting runs, and live trading operations.
* </p>
*/
public interface BarSeries extends Serializable {
/**
* @return factory that generates numbers usable in this BarSeries
*/
NumFactory numFactory();
/**
* @return builder that generates compatible bars
*/
BarBuilder barBuilder();
/**
* @return the name of the series
*/
String getName();
/**
* Gets the bar from {@link #getBarData()} with index {@code i}.
*
* <p>
* The given {@code i} can return the same bar within the first range of indices
* due to {@link #setMaximumBarCount(int)}, for example: If you fill a BarSeries
* with 30 bars and then apply a {@code maximumBarCount} of 10 bars, the first
* 20 bars will be removed from the BarSeries. The indices going further from 0
* to 29 remain but return the same bar from 0 to 20. The remaining 9 bars are
* returned from index 21.
*
* @param i the index
* @return the bar at the i-th position
*/
Bar getBar(int i);
/**
* @return the first bar of the series
*/
default Bar getFirstBar() {
return getBar(getBeginIndex());
}
/**
* @return the last bar of the series
*/
default Bar getLastBar() {
return getBar(getEndIndex());
}
/**
* @return the number of bars in the series
*/
int getBarCount();
/**
* @return true if the series is empty, false otherwise
*/
default boolean isEmpty() {
return getBarCount() == 0;
}
/**
* Returns the raw bar data, i.e. it returns the current list object, which is
* used internally to store the {@link Bar bars}. It may be:
*
* <ul>
* <li>a shortened bar list if a {@code maximumBarCount} has been set.
* <li>an extended bar list if it is a constrained bar series.
* </ul>
*
* <p>
* <b>Warning:</b> This method should be used carefully!
*
* @return the raw bar data
*/
List<Bar> getBarData();
/**
* @return the begin index of the series
*/
int getBeginIndex();
/**
* @return the end index of the series
*/
int getEndIndex();
/**
* @return the description of the series period (e.g. "from 2014-01-21T12:00:00Z
* to 2014-01-21T12:15:00Z"); times are in UTC.
*/
default String getSeriesPeriodDescription() {
StringBuilder sb = new StringBuilder();
if (!getBarData().isEmpty()) {
var endTimeFirstBar = getFirstBar().getEndTime();
var endTimeLastBar = getLastBar().getEndTime();
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
sb.append(formatter.format(endTimeFirstBar)).append(" - ").append(formatter.format(endTimeLastBar));
}
return sb.toString();
}
/**
* @return the description of the series period (e.g. "from 12:00 21/01/2014 to
* 12:15 21/01/2014"); times are in system's default time zone.
*/
default String getSeriesPeriodDescriptionInSystemTimeZone() {
StringBuilder sb = new StringBuilder();
if (!getBarData().isEmpty()) {
var endTimeFirstBar = getFirstBar().getSystemZonedEndTime();
var endTimeLastBar = getLastBar().getSystemZonedEndTime();
DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
sb.append(formatter.format(endTimeFirstBar)).append(" - ").append(formatter.format(endTimeLastBar));
}
return sb.toString();
}
/**
* @return the maximum number of bars
*/
int getMaximumBarCount();
/**
* Sets the maximum number of bars that will be retained in the series.
* <p>
* If a new bar is added to the series such that the number of bars will exceed
* the maximum bar count, then the FIRST bar in the series is automatically
* removed, ensuring that the maximum bar count is not exceeded. The indices of
* the bar series do not change.
*
* @param maximumBarCount the maximum bar count
*/
void setMaximumBarCount(int maximumBarCount);
/**
* @return the number of removed bars
*/
int getRemovedBarsCount();
/**
* Adds the {@code bar} at the end of the series.
*
* <p>
* The {@code beginIndex} is set to {@code 0} if not already initialized.<br>
* The {@code endIndex} is set to {@code 0} if not already initialized, or
* incremented if it matches the end of the series.<br>
* Exceeding bars are removed.
*
* @param bar the bar to be added
* @see BarSeries#setMaximumBarCount(int)
*/
default void addBar(Bar bar) {
addBar(bar, false);
}
/**
* Adds the {@code bar} at the end of the series.
*
* <p>
* The {@code beginIndex} is set to {@code 0} if not already initialized.<br>
* The {@code endIndex} is set to {@code 0} if not already initialized, or
* incremented if it matches the end of the series.<br>
* Exceeding bars are removed.
*
* @param bar the bar to be added
* @param replace true to replace the latest bar. Some exchanges continuously
* provide new bar data in the respective period, e.g. 1 second
* in 1 minute duration. Strategy checks run after a replace
* therefore evaluate an in-progress bar ("live candle"), not a
* closed candle.
* @see BarSeries#setMaximumBarCount(int)
*/
void addBar(Bar bar, boolean replace);
/**
* Adds a trade and updates the close price of the last bar.
*
* @param tradeVolume the traded volume
* @param tradePrice the price
* @see Bar#addTrade(Num, Num)
*/
default void addTrade(Number tradeVolume, Number tradePrice) {
addTrade(numFactory().numOf(tradeVolume), numFactory().numOf(tradePrice));
}
/**
* Adds a trade and updates the close price of the last bar.
*
* @param tradeVolume the traded volume
* @param tradePrice the price
* @see Bar#addTrade(Num, Num)
*/
void addTrade(Num tradeVolume, Num tradePrice);
/**
* Updates the close price of the last bar. The open, high and low prices are
* also updated as needed.
*
* @param price the price for the bar
* @see Bar#addPrice(Num)
*/
void addPrice(Num price);
/**
* Updates the close price of the last bar. The open, high and low prices are
* also updated as needed.
*
* @param price the price for the bar
* @see Bar#addPrice(Num)
*/
default void addPrice(Number price) {
addPrice(numFactory().numOf(price));
}
/**
* Returns a new {@link BarSeries} instance (= "subseries") that is a subset of
* {@code this} BarSeries instance. It contains a copy of all {@link Bar bars}
* between {@code startIndex} (inclusive) and {@code endIndex} (exclusive) of
* {@code this} instance. The indices of {@code this} and its subseries can be
* different, i. e. index 0 of the subseries will be the {@code startIndex} of
* {@code this}. If {@code startIndex} {@literal <} this.seriesBeginIndex, then
* the subseries will start with the first available bar of {@code this}. If
* {@code endIndex} {@literal >} this.seriesEndIndex, then the subseries will
* end at the last available bar of {@code this}.
*
* @param startIndex the startIndex (inclusive)
* @param endIndex the endIndex (exclusive)
* @return a new BarSeries with Bars from startIndex to endIndex-1
* @throws IllegalArgumentException if endIndex {@literal <=} startIndex or
* startIndex {@literal <} 0
*/
BarSeries getSubSeries(int startIndex, int endIndex);
}
@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Interface to build a {@link BarSeries}.
*
* <p>
* Use {@link org.ta4j.core.BaseBarSeriesBuilder} for deterministic
* single-threaded workflows, and
* {@link org.ta4j.core.ConcurrentBarSeriesBuilder} when ingestion and
* evaluation may happen concurrently.
* </p>
*/
public interface BarSeriesBuilder {
/**
* Builds the bar series with corresponding parameters.
*
* @return bar series
*/
BarSeries build();
}
@@ -0,0 +1,222 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
/**
* Base implementation of a {@link Bar}.
*/
public class BaseBar implements Bar {
private static final long serialVersionUID = 8038383777467488147L;
/** The time period (e.g. 1 day, 15 min, etc.) of the bar. */
private final Duration timePeriod;
/** The begin time of the bar period (in UTC). */
private final Instant beginTime;
/** The end time of the bar period (in UTC). */
private final Instant endTime;
/** The open price of the bar period. */
private Num openPrice;
/** The high price of the bar period. */
private Num highPrice;
/** The low price of the bar period. */
private Num lowPrice;
/** The close price of the bar period. */
private Num closePrice;
/** The total traded volume of the bar period. */
private Num volume;
/** The total traded amount of the bar period. */
private Num amount;
/** The number of trades of the bar period. */
private long trades;
/**
* Constructor.
*
* <ul>
* <li>If {@link #timePeriod} is not provided, it will be calculated as
* {@link #endTime} - {@link #beginTime}.
* <li>If {@link #beginTime} is not provided, it will be calculated as
* {@link #endTime} - {@link #timePeriod}.
* <li>If {@link #endTime} is not provided, it will be calculated as
* {@link #beginTime} + {@link #timePeriod}.
* </ul>
*
* @param timePeriod the time period (optional if beginTime and endTime is
* given)
* @param beginTime the begin time of the bar period (in UTC) (optional if
* endTime is given)
* @param endTime the end time of the bar period (in UTC) (optional if
* beginTime is given)
* @param openPrice the open price of the bar period
* @param highPrice the highest price of the bar period
* @param lowPrice the lowest price of the bar period
* @param closePrice the close price of the bar period
* @param volume the total traded volume of the bar period
* @param amount the total traded amount of the bar period
* @param trades the number of trades of the bar period
* @throws NullPointerException if given or calculated {@link #timePeriod},
* {@link #beginTime} or {@link #endTime}
* values are {@code null}
* @throws IllegalArgumentException If the calculated timePeriod between the
* provided beginTime and endTime does not
* match the provided timePeriod
*/
public BaseBar(Duration timePeriod, Instant beginTime, Instant endTime, Num openPrice, Num highPrice, Num lowPrice,
Num closePrice, Num volume, Num amount, long trades) {
// set timePeriod
if (timePeriod != null) {
if (beginTime != null && endTime != null
&& timePeriod.compareTo(Duration.between(beginTime, endTime)) != 0) {
throw new IllegalArgumentException(
"The calculated timePeriod between beginTime and endTime does not match the given timePeriod.");
}
this.timePeriod = timePeriod;
} else {
this.timePeriod = beginTime != null && endTime != null ? Duration.between(beginTime, endTime)
: Objects.requireNonNull(timePeriod, "Time period cannot be null");
}
// set beginTime
if (beginTime == null && endTime != null) {
this.beginTime = endTime.minus(timePeriod);
} else {
this.beginTime = Objects.requireNonNull(beginTime, "Begin time cannot be null");
}
// set endTime
if (beginTime != null && endTime == null) {
this.endTime = beginTime.plus(timePeriod);
} else {
this.endTime = Objects.requireNonNull(endTime, "End time cannot be null");
}
this.openPrice = openPrice;
this.highPrice = highPrice;
this.lowPrice = lowPrice;
this.closePrice = closePrice;
this.volume = volume;
this.amount = amount;
this.trades = trades;
}
@Override
public Duration getTimePeriod() {
return timePeriod;
}
@Override
public Instant getBeginTime() {
return beginTime;
}
@Override
public Instant getEndTime() {
return endTime;
}
@Override
public Num getOpenPrice() {
return openPrice;
}
@Override
public Num getHighPrice() {
return highPrice;
}
@Override
public Num getLowPrice() {
return lowPrice;
}
@Override
public Num getClosePrice() {
return closePrice;
}
@Override
public Num getVolume() {
return volume;
}
@Override
public Num getAmount() {
return amount;
}
@Override
public long getTrades() {
return trades;
}
@Override
public void addTrade(Num tradeVolume, Num tradePrice) {
addPrice(tradePrice);
volume = volume.plus(tradeVolume);
amount = amount.plus(tradeVolume.multipliedBy(tradePrice));
trades++;
}
@Override
public void addPrice(Num price) {
if (openPrice == null) {
openPrice = price;
}
closePrice = price;
if (highPrice == null || highPrice.isLessThan(price)) {
highPrice = price;
}
if (lowPrice == null || lowPrice.isGreaterThan(price)) {
lowPrice = price;
}
}
/**
* @return {end time, close price, open price, low price, high price, volume}
*/
@Override
public String toString() {
return String.format(
"{end time: %1s, close price: %2s, open price: %3s, low price: %4s high price: %5s, volume: %6s}",
endTime, closePrice, openPrice, lowPrice, highPrice, volume);
}
@Override
public int hashCode() {
return Objects.hash(beginTime, endTime, timePeriod, openPrice, highPrice, lowPrice, closePrice, volume, amount,
trades);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof BaseBar))
return false;
final BaseBar other = (BaseBar) obj;
return Objects.equals(beginTime, other.beginTime) && Objects.equals(endTime, other.endTime)
&& Objects.equals(timePeriod, other.timePeriod) && Objects.equals(openPrice, other.openPrice)
&& Objects.equals(highPrice, other.highPrice) && Objects.equals(lowPrice, other.lowPrice)
&& Objects.equals(closePrice, other.closePrice) && Objects.equals(volume, other.volume)
&& Objects.equals(amount, other.amount) && trades == other.trades;
}
}
@@ -0,0 +1,355 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
import java.io.Serial;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Base implementation of a {@link BarSeries}.
*
* <p>
* This is the default choice for single-threaded backtests and deterministic
* replay workflows. If your pipeline ingests bars/trades concurrently with
* strategy evaluation, prefer {@link ConcurrentBarSeries}.
* </p>
*/
public class BaseBarSeries implements BarSeries {
@Serial
private static final long serialVersionUID = -1878027009398790126L;
/**
* The logger.
*/
private final transient Logger log = LoggerFactory.getLogger(getClass());
/**
* The name of the bar series.
*/
private final String name;
/**
* The list of bars of the bar series.
*/
private final List<Bar> bars;
private final BarBuilderFactory barBuilderFactory;
private final NumFactory numFactory;
/**
* True if the current bar series is constrained (i.e. its indexes cannot
* change), false otherwise.
*/
private final boolean constrained;
/**
* The begin index of the bar series
*/
private int seriesBeginIndex = -1;
/**
* The end index of the bar series.
*/
private int seriesEndIndex = -1;
/**
* The maximum number of bars for the bar series.
*/
private int maximumBarCount = Integer.MAX_VALUE;
/**
* The number of removed bars.
*/
private int removedBarsCount = 0;
/**
* Convenience constructor for BaseBarSeries minimizing upfront parameter
* setting. Defaults to Time-based bars, and DecimalNum values
*
* @param name the name of the bar series
* @param bars the list of bars of the bar series
*/
public BaseBarSeries(final String name, final List<Bar> bars) {
this(name, bars, 0, bars.size() - 1, false, DecimalNumFactory.getInstance(), new TimeBarBuilderFactory());
}
/**
* Constructor.
*
* @param name the name of the bar series
* @param bars the list of bars of the bar series
* @param seriesBeginIndex the begin index (inclusive) of the bar series
* @param seriesEndIndex the end index (inclusive) of the bar series
* @param constrained true to constrain the bar series (i.e. indexes
* cannot change), false otherwise
* @param numFactory the factory of numbers used in series {@link Num Num
* implementation}
* @param barBuilderFactory factory for creating bars of this series
*/
BaseBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory) {
this.name = name;
this.numFactory = numFactory;
this.bars = new ArrayList<>(bars);
this.barBuilderFactory = Objects.requireNonNull(barBuilderFactory);
if (bars.isEmpty()) {
// Bar list empty
this.constrained = false;
return;
}
// Bar list not empty: checking indexes
if (seriesEndIndex < seriesBeginIndex - 1) {
throw new IllegalArgumentException("End index must be >= to begin index - 1");
}
if (seriesEndIndex >= bars.size()) {
throw new IllegalArgumentException("End index must be < to the bar list size");
}
this.seriesBeginIndex = seriesBeginIndex;
this.seriesEndIndex = seriesEndIndex;
this.constrained = constrained;
}
/**
* Cuts a list of bars into a new list of bars that is a subset of it.
*
* @param bars the list of {@link Bar bars}
* @param startIndex start index of the subset
* @param endIndex end index of the subset
* @return a new list of bars with tick from startIndex (inclusive) to endIndex
* (exclusive)
*/
private static List<Bar> cut(final List<Bar> bars, final int startIndex, final int endIndex) {
return new ArrayList<>(bars.subList(startIndex, endIndex));
}
/**
* @param series a bar series
* @param index an out-of-bounds bar index
* @return a message for an OutOfBoundsException
*/
private static String buildOutOfBoundsMessage(final BaseBarSeries series, final int index) {
return String.format("Size of series: %s bars, %s bars removed, index = %s", series.bars.size(),
series.removedBarsCount, index);
}
@Override
public BaseBarSeries getSubSeries(final int startIndex, final int endIndex) {
if (startIndex < 0) {
throw new IllegalArgumentException(String.format("the startIndex: %s must not be negative", startIndex));
}
if (startIndex >= endIndex) {
throw new IllegalArgumentException(
String.format("the endIndex: %s must be greater than startIndex: %s", endIndex, startIndex));
}
var builder = new BaseBarSeriesBuilder().withName(getName())
.withNumFactory(this.numFactory)
.withMaxBarCount(this.maximumBarCount);
if (!this.bars.isEmpty()) {
var removedBarsCount = getRemovedBarsCount();
var start = startIndex - removedBarsCount;
var end = Math.min(endIndex - removedBarsCount, this.getEndIndex() + 1);
return builder.withBars(cut(this.bars, start, end)).build();
}
return builder.build();
}
@Override
public NumFactory numFactory() {
return this.numFactory;
}
@Override
public BarBuilder barBuilder() {
return barBuilderFactory.createBarBuilder(this);
}
protected BarBuilderFactory barBuilderFactory() {
return barBuilderFactory;
}
@Override
public String getName() {
return this.name;
}
@Override
public Bar getBar(final int i) {
int innerIndex = i - this.removedBarsCount;
if (innerIndex < 0) {
if (i < 0) {
// Cannot return the i-th bar if i < 0
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, i));
}
if (this.log.isTraceEnabled()) {
this.log.trace("Bar series `{}` ({} bars): bar {} already removed, use {}-th instead", this.name,
this.bars.size(), i, this.removedBarsCount);
}
if (this.bars.isEmpty()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, this.removedBarsCount));
}
innerIndex = 0;
} else if (innerIndex >= this.bars.size()) {
// Cannot return the n-th bar if n >= bars.size()
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, i));
}
return this.bars.get(innerIndex);
}
@Override
public int getBarCount() {
if (this.seriesEndIndex < 0) {
return 0;
}
final int startIndex = Math.max(this.removedBarsCount, this.seriesBeginIndex);
return this.seriesEndIndex - startIndex + 1;
}
@Override
public List<Bar> getBarData() {
return this.bars;
}
@Override
public int getBeginIndex() {
return this.seriesBeginIndex;
}
@Override
public int getEndIndex() {
return this.seriesEndIndex;
}
@Override
public int getMaximumBarCount() {
return this.maximumBarCount;
}
boolean isConstrained() {
return this.constrained;
}
@Override
public void setMaximumBarCount(final int maximumBarCount) {
if (this.constrained) {
throw new IllegalStateException("Cannot set a maximum bar count on a constrained bar series");
}
if (maximumBarCount <= 0) {
throw new IllegalArgumentException("Maximum bar count must be strictly positive");
}
this.maximumBarCount = maximumBarCount;
removeExceedingBars();
}
@Override
public int getRemovedBarsCount() {
return this.removedBarsCount;
}
/**
* @throws NullPointerException if {@code bar} is {@code null}
*/
@Override
public void addBar(final Bar bar, final boolean replace) {
Objects.requireNonNull(bar, "bar must not be null");
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
if (!this.bars.isEmpty()) {
if (replace) {
this.bars.set(this.bars.size() - 1, bar);
return;
}
final int lastBarIndex = this.bars.size() - 1;
final Instant seriesEndTime = this.bars.get(lastBarIndex).getEndTime();
if (!bar.getEndTime().isAfter(seriesEndTime)) {
throw new IllegalArgumentException(
String.format("Cannot add a bar with end time:%s that is <= to series end time: %s",
bar.getEndTime(), seriesEndTime));
}
}
this.bars.add(bar);
if (this.seriesBeginIndex == -1) {
// The begin index is set to 0 if not already initialized:
this.seriesBeginIndex = 0;
}
this.seriesEndIndex++;
removeExceedingBars();
}
/**
* Replaces a bar at the provided series index without changing bar count or
* indices.
*
* @param index the series index to replace
* @param bar the replacement bar
*
* @throws NullPointerException if {@code bar} is {@code null}
* @throws IllegalArgumentException if the bar does not match the series
* numFactory
* @throws IndexOutOfBoundsException if the index is outside the current series
* window
*/
protected void replaceBar(final int index, final Bar bar) {
Objects.requireNonNull(bar, "bar must not be null");
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
if (index < this.seriesBeginIndex || index > this.seriesEndIndex || this.bars.isEmpty()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, index));
}
final int innerIndex = index - this.removedBarsCount;
if (innerIndex < 0 || innerIndex >= this.bars.size()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, index));
}
this.bars.set(innerIndex, bar);
}
@Override
public void addTrade(final Number tradeVolume, final Number tradePrice) {
addTrade(numFactory().numOf(tradeVolume), numFactory().numOf(tradePrice));
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice) {
getLastBar().addTrade(tradeVolume, tradePrice);
}
@Override
public void addPrice(final Num price) {
getLastBar().addPrice(price);
}
/**
* Removes the first N bars that exceed the {@link #maximumBarCount}.
*/
protected void removeExceedingBars() {
final int barCount = this.bars.size();
if (barCount > this.maximumBarCount) {
// Removing old bars
final int nbBarsToRemove = barCount - this.maximumBarCount;
if (nbBarsToRemove == 1) {
this.bars.removeFirst();
} else {
this.bars.subList(0, nbBarsToRemove).clear();
}
// Updating removed bars count
this.removedBarsCount += nbBarsToRemove;
this.seriesBeginIndex = Math.max(this.seriesBeginIndex, this.removedBarsCount);
}
}
}
@@ -0,0 +1,145 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.ArrayList;
import java.util.List;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
/**
* A builder to build a new {@link BaseBarSeries}.
*/
public class BaseBarSeriesBuilder implements BarSeriesBuilder {
/** The {@link #name} for an unnamed bar series. */
private static final String UNNAMED_SERIES_NAME = "unnamed_series";
private List<Bar> bars;
private String name;
private boolean constrained;
private int maxBarCount;
private boolean isNumFactoryAssigned = false;
private NumFactory numFactory = DecimalNumFactory.getInstance();
private BarBuilderFactory barBuilderFactory = new TimeBarBuilderFactory();
/** Constructor to build a {@code BaseBarSeries}. */
public BaseBarSeriesBuilder() {
initValues();
}
private void initValues() {
this.bars = new ArrayList<>();
this.name = "unnamed_series";
this.constrained = false;
this.maxBarCount = Integer.MAX_VALUE;
}
@Override
public BaseBarSeries build() {
int beginIndex = -1;
int endIndex = -1;
if (!bars.isEmpty()) {
beginIndex = 0;
endIndex = bars.size() - 1;
if (!isNumFactoryAssigned) {
// use numFactory derived from bars instead of default numFactory
numFactory = bars.getFirst().numFactory();
}
// check if each bar has the same numFactory as the series numFactory
for (var bar : bars) {
if (bar.getClosePrice() != null) {
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
}
}
}
var series = new BaseBarSeries(name == null ? UNNAMED_SERIES_NAME : name, bars, beginIndex, endIndex,
constrained, numFactory, barBuilderFactory);
series.setMaximumBarCount(maxBarCount);
initValues(); // reinitialize values for next series
return series;
}
/**
* @param constrained to set
* @return {@code this}
*
* @deprecated Constrained mode is being derived from max-bar-count
* configuration instead of being set directly. Prefer configuring
* retention via {@link #withMaxBarCount(int)} (or omit it for the
* default constrained behavior).
*/
@Deprecated(since = "0.22.2")
public BaseBarSeriesBuilder setConstrained(boolean constrained) {
this.constrained = constrained;
return this;
}
/**
* @param numFactory to set {@link BaseBarSeries#numFactory()} (by default, uses
* either {@link DecimalNumFactory} or {@code numFactory}
* derived from {@link #bars})
* @return {@code this}
*/
public BaseBarSeriesBuilder withNumFactory(NumFactory numFactory) {
if (numFactory != null) {
// user has explicitly assigned a numFactory
isNumFactoryAssigned = true;
}
this.numFactory = numFactory;
return this;
}
/**
* @param name to set {@link BaseBarSeries#getName()}
* @return {@code this}
*/
public BaseBarSeriesBuilder withName(String name) {
this.name = name;
return this;
}
/**
* @param bars to set {@link BaseBarSeries#getBarData()}; If {@link #numFactory}
* is not assigned by {@link #withNumFactory(NumFactory)},
* {@link #numFactory} defaults to the {@code numFactory} of the
* {@code bars}.
* @return {@code this}
*/
public BaseBarSeriesBuilder withBars(List<Bar> bars) {
this.bars = bars;
return this;
}
/**
* @param maxBarCount to set {@link BaseBarSeries#getMaximumBarCount()}
* @return {@code this}
*/
public BaseBarSeriesBuilder withMaxBarCount(int maxBarCount) {
this.maxBarCount = maxBarCount;
return this;
}
/**
* @param barBuilderFactory to build bars with the same datatype as series (by
* default, uses {@link TimeBarBuilderFactory})
*
* @return {@code this}
*/
public BaseBarSeriesBuilder withBarBuilderFactory(final BarBuilderFactory barBuilderFactory) {
this.barBuilderFactory = barBuilderFactory;
return this;
}
}
@@ -0,0 +1,210 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* {@link Bar} implementation that tracks realtime side and liquidity
* breakdowns.
*
* @since 0.22.2
*/
public class BaseRealtimeBar extends BaseBar implements RealtimeBar {
private static final long serialVersionUID = -5572746191534014724L;
private final NumFactory numFactory;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
/**
* Constructor.
*
* @param timePeriod the time period (optional if beginTime and endTime is
* given)
* @param beginTime the begin time of the bar period (in UTC) (optional if
* endTime is given)
* @param endTime the end time of the bar period (in UTC) (optional if
* beginTime is given)
* @param openPrice the open price of the bar period
* @param highPrice the highest price of the bar period
* @param lowPrice the lowest price of the bar period
* @param closePrice the close price of the bar period
* @param volume the total traded volume of the bar period
* @param amount the total traded amount of the bar period
* @param trades the number of trades of the bar period
* @param buyVolume buy-side volume
* @param sellVolume sell-side volume
* @param buyAmount buy-side amount
* @param sellAmount sell-side amount
* @param buyTrades buy-side trades
* @param sellTrades sell-side trades
* @param makerVolume maker-side volume
* @param takerVolume taker-side volume
* @param makerAmount maker-side amount
* @param takerAmount taker-side amount
* @param makerTrades maker-side trades
* @param takerTrades taker-side trades
* @param hasSideData {@code true} if side data was provided
* @param hasLiquidity {@code true} if liquidity data was provided
* @param numFactory the number factory backing this bar
*
* @since 0.22.2
*/
public BaseRealtimeBar(final Duration timePeriod, final Instant beginTime, final Instant endTime,
final Num openPrice, final Num highPrice, final Num lowPrice, final Num closePrice, final Num volume,
final Num amount, final long trades, final Num buyVolume, final Num sellVolume, final Num buyAmount,
final Num sellAmount, final long buyTrades, final long sellTrades, final Num makerVolume,
final Num takerVolume, final Num makerAmount, final Num takerAmount, final long makerTrades,
final long takerTrades, final boolean hasSideData, final boolean hasLiquidity,
final NumFactory numFactory) {
super(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice, volume, amount, trades);
this.numFactory = Objects.requireNonNull(numFactory, "numFactory cannot be null");
this.buyVolume = buyVolume;
this.sellVolume = sellVolume;
this.buyAmount = buyAmount;
this.sellAmount = sellAmount;
this.buyTrades = buyTrades;
this.sellTrades = sellTrades;
this.hasSideData = hasSideData;
this.makerVolume = makerVolume;
this.takerVolume = takerVolume;
this.makerAmount = makerAmount;
this.takerAmount = takerAmount;
this.makerTrades = makerTrades;
this.takerTrades = takerTrades;
this.hasLiquidityData = hasLiquidity;
}
@Override
public boolean hasSideData() {
return hasSideData;
}
@Override
public boolean hasLiquidityData() {
return hasLiquidityData;
}
@Override
public Num getBuyVolume() {
return buyVolume == null ? numFactory.zero() : buyVolume;
}
@Override
public Num getSellVolume() {
return sellVolume == null ? numFactory.zero() : sellVolume;
}
@Override
public Num getBuyAmount() {
return buyAmount == null ? numFactory.zero() : buyAmount;
}
@Override
public Num getSellAmount() {
return sellAmount == null ? numFactory.zero() : sellAmount;
}
@Override
public long getBuyTrades() {
return buyTrades;
}
@Override
public long getSellTrades() {
return sellTrades;
}
@Override
public Num getMakerVolume() {
return makerVolume == null ? numFactory.zero() : makerVolume;
}
@Override
public Num getTakerVolume() {
return takerVolume == null ? numFactory.zero() : takerVolume;
}
@Override
public Num getMakerAmount() {
return makerAmount == null ? numFactory.zero() : makerAmount;
}
@Override
public Num getTakerAmount() {
return takerAmount == null ? numFactory.zero() : takerAmount;
}
@Override
public long getMakerTrades() {
return makerTrades;
}
@Override
public long getTakerTrades() {
return takerTrades;
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice, final Side side, final Liquidity liquidity) {
super.addTrade(tradeVolume, tradePrice);
addSideData(tradeVolume, tradePrice, side);
addLiquidityData(tradeVolume, tradePrice, liquidity);
}
private void addSideData(final Num tradeVolume, final Num tradePrice, final Side side) {
if (side == null) {
return;
}
hasSideData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (side == Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
private void addLiquidityData(final Num tradeVolume, final Num tradePrice, final Liquidity liquidity) {
if (liquidity == null) {
return;
}
hasLiquidityData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (liquidity == Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
@@ -0,0 +1,334 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.Trade.TradeType;
/**
* Base implementation of a {@link Strategy}.
*/
public class BaseStrategy implements Strategy {
/** The logger. */
protected final Logger log = LoggerFactory.getLogger(getClass());
/** The class name. */
private final String className = getClass().getSimpleName();
/** The name of the strategy. */
private final String name;
/** The entry rule. */
private final Rule entryRule;
/** The exit rule. */
private final Rule exitRule;
/** The entry trade type for this strategy. */
private final TradeType startingType;
/**
* The number of first bars in a bar series that this strategy ignores. During
* the unstable bars of the strategy, any trade placement will be canceled i.e.
* no entry/exit signal will be triggered before {@code index == unstableBars}.
*/
private int unstableBars;
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
*/
public BaseStrategy(Rule entryRule, Rule exitRule) {
this(null, entryRule, exitRule, 0, TradeType.BUY);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
*/
public BaseStrategy(Rule entryRule, Rule exitRule, int unstableBars) {
this(null, entryRule, exitRule, unstableBars, TradeType.BUY);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(Rule entryRule, Rule exitRule, TradeType startingType) {
this(null, entryRule, exitRule, 0, startingType);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(Rule entryRule, Rule exitRule, int unstableBars, TradeType startingType) {
this(null, entryRule, exitRule, unstableBars, startingType);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule) {
this(name, entryRule, exitRule, 0, TradeType.BUY);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, TradeType startingType) {
this(name, entryRule, exitRule, 0, startingType);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @throws IllegalArgumentException if entryRule or exitRule is null
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, int unstableBars) {
this(name, entryRule, exitRule, unstableBars, TradeType.BUY);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @param startingType the entry trade type
* @throws IllegalArgumentException if entryRule or exitRule is null
* @since 0.22.2
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, int unstableBars, TradeType startingType) {
if (entryRule == null || exitRule == null) {
throw new IllegalArgumentException("Rules cannot be null");
}
if (unstableBars < 0) {
throw new IllegalArgumentException("Unstable bars must be >= 0");
}
if (startingType == null) {
throw new IllegalArgumentException("Starting type cannot be null");
}
this.name = name;
this.entryRule = entryRule;
this.exitRule = exitRule;
this.unstableBars = unstableBars;
this.startingType = startingType;
}
@Override
public String getName() {
return name;
}
@Override
public Rule getEntryRule() {
return entryRule;
}
@Override
public Rule getExitRule() {
return exitRule;
}
@Override
public TradeType getStartingType() {
return startingType;
}
@Override
public int getUnstableBars() {
return unstableBars;
}
@Override
public void setUnstableBars(int unstableBars) {
this.unstableBars = unstableBars;
}
@Override
public boolean isUnstableAt(int index) {
return index < unstableBars;
}
@Override
public boolean shouldEnter(int index, TradingRecord tradingRecord) {
return evaluateShouldEnter(index, tradingRecord, Rule.TraceMode.VERBOSE);
}
/**
* {@inheritDoc}
*
* @since 0.22.7
*/
@Override
public boolean shouldEnterWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return evaluateShouldEnter(index, tradingRecord, traceMode);
}
private boolean evaluateShouldEnter(int index, TradingRecord tradingRecord, Rule.TraceMode requestedTraceMode) {
Rule.TraceMode activeTraceMode = requestedTraceMode == null ? Rule.TraceMode.VERBOSE : requestedTraceMode;
boolean traceLoggingEnabled = log.isTraceEnabled();
if (isUnstableAt(index)) {
traceShouldEnter(index, false, traceLoggingEnabled, activeTraceMode, "unstable");
return false;
}
boolean enter = traceLoggingEnabled
? getEntryRule().isSatisfiedWithTraceMode(index, tradingRecord, activeTraceMode)
: getEntryRule().isSatisfied(index, tradingRecord);
traceShouldEnter(index, enter, traceLoggingEnabled, activeTraceMode, enter ? null : "entryRule");
return enter;
}
@Override
public boolean shouldExit(int index, TradingRecord tradingRecord) {
return evaluateShouldExit(index, tradingRecord, Rule.TraceMode.VERBOSE);
}
/**
* {@inheritDoc}
*
* @since 0.22.7
*/
@Override
public boolean shouldExitWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return evaluateShouldExit(index, tradingRecord, traceMode);
}
private boolean evaluateShouldExit(int index, TradingRecord tradingRecord, Rule.TraceMode requestedTraceMode) {
Rule.TraceMode activeTraceMode = requestedTraceMode == null ? Rule.TraceMode.VERBOSE : requestedTraceMode;
boolean traceLoggingEnabled = log.isTraceEnabled();
if (isUnstableAt(index)) {
traceShouldExit(index, false, traceLoggingEnabled, activeTraceMode, "unstable");
return false;
}
boolean exit = traceLoggingEnabled
? getExitRule().isSatisfiedWithTraceMode(index, tradingRecord, activeTraceMode)
: getExitRule().isSatisfied(index, tradingRecord);
traceShouldExit(index, exit, traceLoggingEnabled, activeTraceMode, exit ? null : "exitRule");
return exit;
}
@Override
public Strategy and(Strategy strategy) {
String andName = "and(" + name + "," + strategy.getName() + ")";
int unstable = Math.max(unstableBars, strategy.getUnstableBars());
return and(andName, strategy, unstable);
}
@Override
public Strategy or(Strategy strategy) {
String orName = "or(" + name + "," + strategy.getName() + ")";
int unstable = Math.max(unstableBars, strategy.getUnstableBars());
return or(orName, strategy, unstable);
}
@Override
public Strategy opposite() {
return new BaseStrategy("opposite(" + name + ")", exitRule, entryRule, unstableBars, startingType);
}
@Override
public Strategy and(String name, Strategy strategy, int unstableBars) {
return new BaseStrategy(name, entryRule.and(strategy.getEntryRule()), exitRule.and(strategy.getExitRule()),
unstableBars, getStartingType());
}
@Override
public Strategy or(String name, Strategy strategy, int unstableBars) {
return new BaseStrategy(name, entryRule.or(strategy.getEntryRule()), exitRule.or(strategy.getExitRule()),
unstableBars, getStartingType());
}
/**
* Returns the display name to use in trace logs. Uses the configured name if
* set, otherwise falls back to the class name.
*
* @return display name for tracing
*/
protected String getTraceDisplayName() {
return name != null ? name : className;
}
/**
* Traces the {@code shouldEnter()} method calls.
*
* @param index the bar index
* @param enter true if the strategy should enter, false otherwise
*/
protected void traceShouldEnter(int index, boolean enter) {
traceShouldEnter(index, enter, log.isTraceEnabled(), Rule.TraceMode.VERBOSE, enter ? null : "entryRule");
}
private void traceShouldEnter(int index, boolean enter, boolean traceLoggingEnabled, Rule.TraceMode activeTraceMode,
String reason) {
if (traceLoggingEnabled) {
log.trace(">>> {}#shouldEnter({}): {} mode={}{}", getTraceDisplayName(), index, enter, activeTraceMode,
strategyTraceContext(reason));
}
}
/**
* Traces the {@code shouldExit()} method calls.
*
* @param index the bar index
* @param exit true if the strategy should exit, false otherwise
*/
protected void traceShouldExit(int index, boolean exit) {
traceShouldExit(index, exit, log.isTraceEnabled(), Rule.TraceMode.VERBOSE, exit ? null : "exitRule");
}
private void traceShouldExit(int index, boolean exit, boolean traceLoggingEnabled, Rule.TraceMode activeTraceMode,
String reason) {
if (traceLoggingEnabled) {
log.trace(">>> {}#shouldExit({}): {} mode={}{}", getTraceDisplayName(), index, exit, activeTraceMode,
strategyTraceContext(reason));
}
}
private String strategyTraceContext(String reason) {
if (reason == null) {
return "";
}
if ("unstable".equals(reason)) {
return " reason=unstable unstableBars=" + unstableBars;
}
return " reason=" + reason;
}
}
@@ -0,0 +1,620 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.Serial;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* Unified {@link Trade} implementation for backtest and live flows.
*
* <ul>
* <li>the index (in the {@link BarSeries bar series}) on which the trade is
* executed
* <li>a {@link Trade.TradeType type} (BUY or SELL)
* <li>a pricePerAsset (optional)
* <li>a trade amount (optional)
* </ul>
*
* A {@link Position position} is a pair of complementary trades.
*
* <p>
* Trades are backed by one or more {@link TradeFill} entries. Scalar
* constructors create a single fill; aggregated constructors preserve full fill
* progression.
* </p>
*
* @since 0.22.4
*/
public class BaseTrade implements Trade {
@Serial
private static final long serialVersionUID = -905474949010114150L;
private static final Gson GSON = new Gson();
private static final CostModel DEFAULT_COST_MODEL = new ZeroCostModel();
/** The type of the trade. */
private final Trade.TradeType type;
/** The index the trade was executed. */
private final int index;
/** The trade price per asset. */
private Num pricePerAsset;
/**
* The net price per asset for the trade (i.e. {@link #pricePerAsset} with
* {@link #cost}).
*/
private Num netPrice;
/** The trade amount. */
private final Num amount;
/** Execution fills for this trade (single fill for scalar trades). */
private final List<TradeFill> fills;
/** Execution timestamp. */
private final Instant time;
/** Execution side. */
private final ExecutionSide side;
/** Optional order id. */
private final String orderId;
/** Optional correlation id. */
private final String correlationId;
/**
* The simulated execution cost for this trade, derived from the configured
* {@link CostModel}.
*/
private Num cost;
/** The cost model for trade execution. */
private transient CostModel costModel;
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type) {
this(index, series, type, series.numFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type, Num amount) {
this(index, series, type, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution cost
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type, Num amount, CostModel transactionCostModel) {
Num executionPrice = series.getBar(index).getClosePrice();
Instant executionTime = series.getBar(index).getEndTime();
this.type = type;
this.index = index;
this.amount = amount;
this.time = executionTime;
this.side = executionSide(type);
this.orderId = null;
this.correlationId = null;
this.fills = List.of(new TradeFill(index, executionTime, executionPrice, amount, side));
setPricesAndCost(executionPrice, amount, transactionCostModel, this.fills);
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset) {
this(index, type, pricePerAsset, pricePerAsset.getNumFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount) {
this(index, type, pricePerAsset, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount,
CostModel transactionCostModel) {
this.type = type;
this.index = index;
this.amount = amount;
this.time = null;
this.side = executionSide(type);
this.orderId = null;
this.correlationId = null;
this.fills = List.of(new TradeFill(index, null, pricePerAsset, amount, side));
setPricesAndCost(pricePerAsset, amount, transactionCostModel, this.fills);
}
/**
* Constructor for multi-fill trades.
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @param transactionCostModel the cost model for trade execution
* @since 0.22.4
*/
protected BaseTrade(Trade.TradeType type, List<TradeFill> fills, CostModel transactionCostModel) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
FillSummary fillSummary = summarizeFills(type, fills);
FillMetadata metadata = summarizeMetadata(type, fillSummary.firstFill());
this.type = type;
this.index = fillSummary.firstFill().index();
this.amount = fillSummary.totalAmount();
this.time = metadata.time();
this.side = metadata.side();
this.orderId = metadata.orderId();
this.correlationId = metadata.correlationId();
this.fills = fillSummary.fills();
setPricesAndCost(fillSummary.weightedAveragePrice(), fillSummary.totalAmount(), transactionCostModel,
this.fills);
}
/**
* Constructor for live execution trades.
*
* @param index trade index
* @param time execution timestamp
* @param pricePerAsset execution price per asset
* @param amount execution amount
* @param fee recorded execution fee (nullable, defaults to zero)
* @param side execution side
* @param orderId optional order id
* @param correlationId optional correlation id
* @since 0.22.4
*/
public BaseTrade(int index, Instant time, Num pricePerAsset, Num amount, Num fee, ExecutionSide side,
String orderId, String correlationId) {
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
Objects.requireNonNull(time, "time");
Objects.requireNonNull(pricePerAsset, "pricePerAsset");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(side, "side");
Num normalizedFee = fee == null ? pricePerAsset.getNumFactory().zero() : fee;
this.type = side.toTradeType();
this.index = index;
this.amount = amount;
this.time = time;
this.side = side;
this.orderId = orderId;
this.correlationId = correlationId;
this.fills = List
.of(new TradeFill(index, time, pricePerAsset, amount, normalizedFee, side, orderId, correlationId));
setPricesAndCost(pricePerAsset, amount, RecordedTradeCostModel.INSTANCE, this.fills);
}
@Override
public Trade.TradeType getType() {
return type;
}
@Override
public Num getCost() {
return cost;
}
@Override
public int getIndex() {
return index;
}
@Override
public Num getPricePerAsset() {
return pricePerAsset;
}
@Override
public Num getPricePerAsset(BarSeries barSeries) {
if (pricePerAsset.isNaN()) {
return barSeries.getBar(index).getClosePrice();
}
return pricePerAsset;
}
@Override
public Num getNetPrice() {
return netPrice;
}
@Override
public Num getAmount() {
return amount;
}
@Override
public Instant getTime() {
return time;
}
@Override
public String getOrderId() {
return orderId;
}
@Override
public String getCorrelationId() {
return correlationId;
}
@Override
public List<TradeFill> getFills() {
return exportedFills();
}
/**
* @return execution timestamp
* @since 0.22.4
*/
public Instant time() {
return time;
}
/**
* @return execution price per asset
* @since 0.22.4
*/
public Num price() {
return pricePerAsset;
}
/**
* @return execution amount
* @since 0.22.4
*/
public Num amount() {
return amount;
}
/**
* @return recorded fee/cost
* @since 0.22.4
*/
public Num fee() {
return cost;
}
/**
* @return execution side
* @since 0.22.4
*/
public ExecutionSide side() {
return side;
}
/**
* @return optional order id
* @since 0.22.4
*/
public String orderId() {
return orderId;
}
/**
* @return optional correlation id
* @since 0.22.4
*/
public String correlationId() {
return correlationId;
}
/**
* @return the configured cost model, or a zero-cost model after deserialization
* when the transient model is unset
*
* @since 0.22.4
*/
@Override
public CostModel getCostModel() {
return costModel == null ? DEFAULT_COST_MODEL : costModel;
}
/**
* Sets the raw and net prices of the trade.
*
* @param pricePerAsset the raw price of the asset
* @param amount the amount of assets ordered
* @param transactionCostModel the cost model for trade execution
*/
private void setPricesAndCost(Num pricePerAsset, Num amount, CostModel transactionCostModel,
List<TradeFill> fills) {
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
this.costModel = transactionCostModel;
this.pricePerAsset = pricePerAsset;
this.cost = resolveCost(transactionCostModel, this.pricePerAsset, amount, fills);
if (amount.isZero()) {
this.netPrice = this.pricePerAsset;
return;
}
Num costPerAsset = cost.dividedBy(amount);
// add transaction costs to the pricePerAsset at the trade
if (type.equals(Trade.TradeType.BUY)) {
this.netPrice = this.pricePerAsset.plus(costPerAsset);
} else {
this.netPrice = this.pricePerAsset.minus(costPerAsset);
}
}
private static Num resolveCost(CostModel transactionCostModel, Num pricePerAsset, Num amount,
List<TradeFill> fills) {
if (transactionCostModel instanceof RecordedTradeCostModel) {
return sumFillFees(pricePerAsset.getNumFactory().zero(), fills);
}
return transactionCostModel.calculate(pricePerAsset, amount);
}
private static Num sumFillFees(Num zero, List<TradeFill> fills) {
Num totalFee = zero;
for (TradeFill fill : fills) {
totalFee = totalFee.plus(fill.fee());
}
return totalFee;
}
private static FillSummary summarizeFills(Trade.TradeType tradeType, List<TradeFill> fills) {
Objects.requireNonNull(fills, "fills");
if (fills.isEmpty()) {
throw new IllegalArgumentException("fills must not be empty");
}
Num totalAmount = fills.getFirst().amount().getNumFactory().zero();
Num weightedPrice = fills.getFirst().price().getNumFactory().zero();
TradeFill earliestFill = fills.getFirst();
ExecutionSide expectedSide = executionSide(tradeType);
for (TradeFill fill : fills) {
if (fill.side() != null && fill.side() != expectedSide) {
throw new IllegalArgumentException("fill side must match trade type at index " + fill.index());
}
if (fill.price().isNaN()) {
throw new IllegalArgumentException("fill price must be set");
}
if (fill.amount().isNaN() || fill.amount().isZero() || fill.amount().isNegative()) {
throw new IllegalArgumentException("fill amount must be positive");
}
if (fill.index() < earliestFill.index()) {
earliestFill = fill;
}
totalAmount = totalAmount.plus(fill.amount());
weightedPrice = weightedPrice.plus(fill.price().multipliedBy(fill.amount()));
}
return new FillSummary(List.copyOf(fills), earliestFill, totalAmount, weightedPrice.dividedBy(totalAmount));
}
private static FillMetadata summarizeMetadata(Trade.TradeType tradeType, TradeFill firstFill) {
Instant firstTime = firstFill.time();
String firstOrderId = firstFill.orderId();
String firstCorrelationId = firstFill.correlationId();
ExecutionSide resolvedSide = firstFill.side() == null ? executionSide(tradeType) : firstFill.side();
return new FillMetadata(firstTime, resolvedSide, firstOrderId, firstCorrelationId);
}
/**
* Exports fills with trade-level modeled costs apportioned back onto the fills
* when no explicit per-fill fees were recorded.
*/
private List<TradeFill> exportedFills() {
if (fills.isEmpty() || cost == null || cost.isNaN()) {
return fills;
}
CostModel effectiveCostModel = getCostModel();
if (effectiveCostModel instanceof RecordedTradeCostModel) {
return fills;
}
Num zero = fills.getFirst().price().getNumFactory().zero();
Num recordedFeeTotal = sumFillFees(zero, fills);
Num residualFee = cost.minus(recordedFeeTotal);
if (!residualFee.isPositive()) {
return fills;
}
Num totalWeight = totalFillWeight(zero);
if (totalWeight.isZero()) {
return fills;
}
Num remainingFee = residualFee;
List<TradeFill> adjustedFills = new ArrayList<>(fills.size());
for (int i = 0; i < fills.size(); i++) {
TradeFill fill = fills.get(i);
Num feeShare = i == fills.size() - 1 ? remainingFee
: residualFee.multipliedBy(fillWeight(fill)).dividedBy(totalWeight);
remainingFee = remainingFee.minus(feeShare);
adjustedFills.add(copyWithFee(fill, fill.fee().plus(feeShare)));
}
return List.copyOf(adjustedFills);
}
private Num totalFillWeight(Num zero) {
Num totalWeight = zero;
for (TradeFill fill : fills) {
totalWeight = totalWeight.plus(fillWeight(fill));
}
return totalWeight;
}
private Num fillWeight(TradeFill fill) {
return fill.price().multipliedBy(fill.amount());
}
private TradeFill copyWithFee(TradeFill fill, Num fee) {
return new TradeFill(fill.index(), fill.time(), fill.price(), fill.amount(), fee, fill.side(), fill.orderId(),
fill.correlationId());
}
private static ExecutionSide executionSide(Trade.TradeType tradeType) {
if (tradeType == Trade.TradeType.BUY) {
return ExecutionSide.BUY;
}
return ExecutionSide.SELL;
}
@Override
public boolean isBuy() {
return type == Trade.TradeType.BUY;
}
@Override
public boolean isSell() {
return type == Trade.TradeType.SELL;
}
@Override
public int hashCode() {
return Objects.hash(type, index, time, pricePerAsset, amount, cost, side, orderId, correlationId);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof BaseTrade other)) {
return false;
}
return Objects.equals(type, other.type) && Objects.equals(index, other.index)
&& Objects.equals(time, other.time) && Objects.equals(pricePerAsset, other.pricePerAsset)
&& Objects.equals(amount, other.amount) && Objects.equals(cost, other.cost)
&& Objects.equals(side, other.side) && Objects.equals(orderId, other.orderId)
&& Objects.equals(correlationId, other.correlationId);
}
@Override
public String toString() {
JsonObject json = new JsonObject();
json.addProperty("type", type == null ? null : type.name());
json.addProperty("index", index);
json.addProperty("time", time == null ? null : time.toString());
json.addProperty("pricePerAsset", pricePerAsset == null ? null : pricePerAsset.toString());
json.addProperty("netPrice", netPrice == null ? null : netPrice.toString());
json.addProperty("amount", amount == null ? null : amount.toString());
json.addProperty("cost", cost == null ? null : cost.toString());
json.addProperty("side", side == null ? null : side.name());
json.addProperty("orderId", orderId);
json.addProperty("correlationId", correlationId);
return GSON.toJson(json);
}
/**
* Returns a copy of this trade with a new index.
*
* @param index trade index
* @return trade with the provided index
* @since 0.22.4
*/
public BaseTrade withIndex(int index) {
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
int delta = index - this.index;
List<TradeFill> indexedFills = fills.stream()
.map(fill -> new TradeFill(fill.index() + delta, fill.time(), fill.price(), fill.amount(), fill.fee(),
fill.side(), fill.orderId(), fill.correlationId()))
.toList();
return new BaseTrade(type, indexedFills, resolveCopyCostModel(indexedFills));
}
private CostModel resolveCopyCostModel(List<TradeFill> indexedFills) {
if (costModel != null) {
return costModel;
}
Num fillFeeTotal = sumFillFees(cost.getNumFactory().zero(), indexedFills);
if (cost.equals(fillFeeTotal)) {
return RecordedTradeCostModel.INSTANCE;
}
return new PreservedTradeCostModel(cost);
}
private record FillSummary(List<TradeFill> fills, TradeFill firstFill, Num totalAmount, Num weightedAveragePrice) {
}
private record FillMetadata(Instant time, ExecutionSide side, String orderId, String correlationId) {
}
private static final class PreservedTradeCostModel implements CostModel {
private final Num preservedCost;
private PreservedTradeCostModel(Num preservedCost) {
this.preservedCost = Objects.requireNonNull(preservedCost, "preservedCost");
}
@Override
public Num calculate(Position position, int finalIndex) {
return preservedCost;
}
@Override
public Num calculate(Position position) {
return preservedCost;
}
@Override
public Num calculate(Num price, Num amount) {
return preservedCost;
}
@Override
public boolean equals(CostModel otherModel) {
if (!(otherModel instanceof PreservedTradeCostModel other)) {
return false;
}
return preservedCost.equals(other.preservedCost);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import org.ta4j.core.num.Num;
import java.io.ObjectInputStream;
import java.io.IOException;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
import java.util.Collection;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.Objects;
import java.time.Instant;
import java.util.List;
/**
* Thread-safe {@link BarSeries} implementation for concurrent read/write use
* cases.
*
* <p>
* Choose this type only when ingestion and evaluation can overlap on different
* threads. For single-threaded backtests and deterministic replay pipelines,
* {@link BaseBarSeries} is usually simpler.
* </p>
*
* <p>
* For real-time data feeds, prefer {@link #ingestTrade(Instant, Num, Num)} and
* {@link #ingestTrade(Instant, Number, Number)} to let the configured
* {@link BarBuilder} handle bar rollovers. Direct bar mutations remain
* available for reconciliation and data correction workflows.
*
* <p>
* Java serialization preserves bar data, the {@link NumFactory}, and the
* {@link BarBuilderFactory} configuration. Transient locks are reinitialized on
* deserialization, and the trade bar builder is recreated lazily on the next
* ingestion call.
*
* @since 0.22.2
*/
public class ConcurrentBarSeries extends BaseBarSeries {
private static final long serialVersionUID = -1868546230609071876L;
private transient Lock readLock;
private transient Lock writeLock;
private transient BarBuilder tradeBarBuilder;
/**
* Indicates how a streaming bar was applied to the series.
*
* @since 0.22.2
*/
public enum StreamingBarIngestAction {
APPENDED, REPLACED_LAST, REPLACED_HISTORICAL
}
/**
* Describes the outcome of ingesting a streaming bar.
*
* @param action indicates how the bar was applied
* @param index the affected series index
*
* @since 0.22.2
*/
public record StreamingBarIngestResult(StreamingBarIngestAction action, int index) {
public StreamingBarIngestResult {
Objects.requireNonNull(action, "action cannot be null");
if (index < 0) {
throw new IllegalArgumentException("index cannot be negative");
}
}
}
ConcurrentBarSeries(final String name, final List<Bar> bars) {
this(name, bars, 0, bars.size() - 1, false, DecimalNumFactory.getInstance(), new TimeBarBuilderFactory(true),
new ReentrantReadWriteLock());
}
ConcurrentBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory) {
this(name, bars, seriesBeginIndex, seriesEndIndex, constrained, numFactory, barBuilderFactory,
new ReentrantReadWriteLock());
}
ConcurrentBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory,
final ReadWriteLock readWriteLock) {
super(name, bars, seriesBeginIndex, seriesEndIndex, constrained, numFactory, barBuilderFactory);
initLocks(readWriteLock);
this.tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder cannot be null");
}
private void initLocks(final ReadWriteLock readWriteLock) {
ReadWriteLock rwLock = Objects.requireNonNull(readWriteLock, "readWriteLock cannot be null");
this.readLock = rwLock.readLock();
this.writeLock = rwLock.writeLock();
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
initLocks(new ReentrantReadWriteLock());
tradeBarBuilder = null;
}
private static List<Bar> cut(final List<Bar> bars, final int startIndex, final int endIndex) {
return new ArrayList<>(bars.subList(startIndex, endIndex));
}
@Override
public ConcurrentBarSeries getSubSeries(final int startIndex, final int endIndex) {
this.readLock.lock();
try {
if (startIndex < 0) {
throw new IllegalArgumentException(String.format("startIndex: %s cannot be negative", startIndex));
}
if (startIndex >= endIndex) {
throw new IllegalArgumentException(
String.format("endIndex: %s must be greater than startIndex: %s", endIndex, startIndex));
}
final List<Bar> bars = super.getBarData();
if (!bars.isEmpty()) {
final int start = startIndex - super.getRemovedBarsCount();
final int end = Math.min(endIndex - super.getRemovedBarsCount(), super.getEndIndex() + 1);
final var builder = new ConcurrentBarSeriesBuilder().withName(getName())
.withBars(cut(bars, start, end))
.withNumFactory(super.numFactory())
.withBarBuilderFactory(super.barBuilderFactory());
if (!isConstrained()) {
builder.withMaxBarCount(super.getMaximumBarCount());
}
return builder.build();
}
final var builder = new ConcurrentBarSeriesBuilder().withNumFactory(super.numFactory())
.withBarBuilderFactory(super.barBuilderFactory())
.withName(getName());
if (!isConstrained()) {
builder.withMaxBarCount(super.getMaximumBarCount());
}
return builder.build();
} finally {
this.readLock.unlock();
}
}
@Override
public BarBuilder barBuilder() {
this.readLock.lock();
try {
return super.barBuilder();
} finally {
this.readLock.unlock();
}
}
@Override
public String getName() {
this.readLock.lock();
try {
return super.getName();
} finally {
this.readLock.unlock();
}
}
@Override
public NumFactory numFactory() {
this.readLock.lock();
try {
return super.numFactory();
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getBar(final int i) {
this.readLock.lock();
try {
return super.getBar(i);
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getFirstBar() {
this.readLock.lock();
try {
return super.getBar(super.getBeginIndex());
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getLastBar() {
this.readLock.lock();
try {
return super.getBar(super.getEndIndex());
} finally {
this.readLock.unlock();
}
}
@Override
public int getBarCount() {
this.readLock.lock();
try {
return super.getBarCount();
} finally {
this.readLock.unlock();
}
}
@Override
public List<Bar> getBarData() {
this.readLock.lock();
try {
return List.copyOf(super.getBarData());
} finally {
this.readLock.unlock();
}
}
@Override
public int getBeginIndex() {
this.readLock.lock();
try {
return super.getBeginIndex();
} finally {
this.readLock.unlock();
}
}
@Override
public int getEndIndex() {
this.readLock.lock();
try {
return super.getEndIndex();
} finally {
this.readLock.unlock();
}
}
@Override
public int getMaximumBarCount() {
this.readLock.lock();
try {
return super.getMaximumBarCount();
} finally {
this.readLock.unlock();
}
}
@Override
public void setMaximumBarCount(final int maximumBarCount) {
this.writeLock.lock();
try {
super.setMaximumBarCount(maximumBarCount);
} finally {
this.writeLock.unlock();
}
}
@Override
public int getRemovedBarsCount() {
this.readLock.lock();
try {
return super.getRemovedBarsCount();
} finally {
this.readLock.unlock();
}
}
/**
* Returns the builder used for streaming trade ingestion. Configure it (for
* example, set the time period) before calling
* {@link #ingestTrade(Instant, Num, Num)}.
*
* @return the trade bar builder
*
* @since 0.22.2
*/
public BarBuilder tradeBarBuilder() {
this.readLock.lock();
try {
if (tradeBarBuilder != null) {
return tradeBarBuilder;
}
} finally {
this.readLock.unlock();
}
this.writeLock.lock();
try {
if (tradeBarBuilder == null) {
tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder cannot be null");
}
return tradeBarBuilder;
} finally {
this.writeLock.unlock();
}
}
/**
* Runs the supplied action while holding the read lock.
*
* @param action read-only action to execute
*
* @since 0.22.2
*/
public void withReadLock(final Runnable action) {
Objects.requireNonNull(action, "action cannot be null");
this.readLock.lock();
try {
action.run();
} finally {
this.readLock.unlock();
}
}
/**
* Runs the supplied action while holding the read lock.
*
* @param action read-only action to execute
* @param <T> return type
* @return the action result
*
* @since 0.22.2
*/
public <T> T withReadLock(final Supplier<T> action) {
Objects.requireNonNull(action, "action cannot be null");
this.readLock.lock();
try {
return action.get();
} finally {
this.readLock.unlock();
}
}
/**
* Runs the supplied action while holding the write lock.
*
* @param action mutating action to execute
*
* @since 0.22.2
*/
public void withWriteLock(final Runnable action) {
Objects.requireNonNull(action, "action cannot be null");
this.writeLock.lock();
try {
action.run();
} finally {
this.writeLock.unlock();
}
}
/**
* Runs the supplied action while holding the write lock.
*
* @param action mutating action to execute
* @param <T> return type
* @return the action result
*
* @since 0.22.2
*/
public <T> T withWriteLock(final Supplier<T> action) {
Objects.requireNonNull(action, "action cannot be null");
this.writeLock.lock();
try {
return action.get();
} finally {
this.writeLock.unlock();
}
}
@Override
public void addBar(final Bar bar, final boolean replace) {
this.writeLock.lock();
try {
super.addBar(bar, replace);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addTrade(final Number tradeVolume, final Number tradePrice) {
this.writeLock.lock();
try {
super.addTrade(tradeVolume, tradePrice);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice) {
this.writeLock.lock();
try {
super.addTrade(tradeVolume, tradePrice);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addPrice(final Num price) {
this.writeLock.lock();
try {
super.addPrice(price);
} finally {
this.writeLock.unlock();
}
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Number tradeVolume, final Number tradePrice) {
ingestTrade(tradeTime, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Num tradeVolume, final Num tradePrice) {
ingestTrade(tradeTime, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Number tradeVolume, final Number tradePrice,
final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(tradeTime, "tradeTime cannot be null");
Objects.requireNonNull(tradeVolume, "tradeVolume cannot be null");
Objects.requireNonNull(tradePrice, "tradePrice cannot be null");
final NumFactory factory = super.numFactory();
ingestTrade(tradeTime, factory.numOf(tradeVolume), factory.numOf(tradePrice), side, liquidity);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Num tradeVolume, final Num tradePrice,
final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(tradeTime, "tradeTime cannot be null");
Objects.requireNonNull(tradeVolume, "tradeVolume cannot be null");
Objects.requireNonNull(tradePrice, "tradePrice cannot be null");
if (!super.numFactory().produces(tradeVolume) || !super.numFactory().produces(tradePrice)) {
throw new IllegalArgumentException(
String.format("Cannot ingest trade with data types: %s/%s into series with datatype: %s",
tradeVolume.getClass(), tradePrice.getClass(), super.numFactory().one().getClass()));
}
this.writeLock.lock();
try {
if (tradeBarBuilder == null) {
tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder");
}
tradeBarBuilder.addTrade(tradeTime, tradeVolume, tradePrice, side, liquidity);
} finally {
this.writeLock.unlock();
}
}
/**
* Ingests a single streaming bar (e.g., one emitted from an exchange WebSocket
* candles) and appends or replaces the matching interval.
*
* <p>
* Unlike {@link #addBar(Bar, boolean)}, this method can replace historical bars
* when exchanges replay snapshots that include prior intervals.
*
* @param bar streaming bar payload
* @return the applied action and affected series index
*
* @since 0.22.2
*/
public StreamingBarIngestResult ingestStreamingBar(final Bar bar) {
Objects.requireNonNull(bar, "bar cannot be null");
this.writeLock.lock();
try {
return addStreamingBarUnsafe(bar);
} finally {
this.writeLock.unlock();
}
}
/**
* Bulk-ingests streaming bars. Incoming payloads are sorted by their end time
* to gracefully handle candle snapshots that are emitted with the most recent
* intervals first.
*
* @param bars streaming bars to ingest
* @return applied actions in ascending end-time order
*
* @since 0.22.2
*/
public List<StreamingBarIngestResult> ingestStreamingBars(final Collection<Bar> bars) {
if (bars == null || bars.isEmpty()) {
return List.of();
}
final List<Bar> ordered = new ArrayList<>(bars);
ordered.removeIf(Objects::isNull);
if (ordered.isEmpty()) {
return List.of();
}
ordered.sort(Comparator.comparing(Bar::getEndTime));
this.writeLock.lock();
try {
final List<StreamingBarIngestResult> results = new ArrayList<>(ordered.size());
for (Bar bar : ordered) {
results.add(addStreamingBarUnsafe(bar));
}
return List.copyOf(results);
} finally {
this.writeLock.unlock();
}
}
private StreamingBarIngestResult addStreamingBarUnsafe(final Bar newBar) {
validateBarMatchesSeries(newBar);
final List<Bar> internal = super.getBarData();
if (internal.isEmpty()) {
super.addBar(newBar, false);
return new StreamingBarIngestResult(StreamingBarIngestAction.APPENDED, super.getEndIndex());
}
final Instant newEndTime = Objects.requireNonNull(newBar.getEndTime(), "Bar endTime cannot be null");
final Bar lastBar = internal.get(internal.size() - 1);
final Instant lastEndTime = Objects.requireNonNull(lastBar.getEndTime(), "Last bar endTime cannot be null");
int endTimeComparison = newEndTime.compareTo(lastEndTime);
if (endTimeComparison == 0) {
super.addBar(newBar, true);
return new StreamingBarIngestResult(StreamingBarIngestAction.REPLACED_LAST, super.getEndIndex());
}
if (endTimeComparison > 0) {
super.addBar(newBar, false);
return new StreamingBarIngestResult(StreamingBarIngestAction.APPENDED, super.getEndIndex());
}
final int internalIndex = findBarIndexByEndTime(internal, newEndTime);
if (internalIndex >= 0) {
final int seriesIndex = internalIndex + super.getRemovedBarsCount();
// Replacing a historical bar doesn't change bar count or indices, so we bypass
// addBar().
super.replaceBar(seriesIndex, newBar);
return new StreamingBarIngestResult(StreamingBarIngestAction.REPLACED_HISTORICAL, seriesIndex);
}
throw new IllegalArgumentException(
String.format("Cannot insert streaming bar ending at %s because series end time is %s",
newBar.getEndTime(), lastBar.getEndTime()));
}
private void validateBarMatchesSeries(final Bar bar) {
if (!super.numFactory().produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), super.numFactory().one().getClass()));
}
}
private static int findBarIndexByEndTime(final List<Bar> bars, final Instant endTime) {
int low = 0;
int high = bars.size() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
final Instant midTime = bars.get(mid).getEndTime();
int comparison = midTime.compareTo(endTime);
if (comparison < 0) {
low = mid + 1;
} else if (comparison > 0) {
high = mid - 1;
} else {
return mid;
}
}
return -1;
}
@Override
public String getSeriesPeriodDescription() {
this.readLock.lock();
try {
return super.getSeriesPeriodDescription();
} finally {
this.readLock.unlock();
}
}
@Override
public String getSeriesPeriodDescriptionInSystemTimeZone() {
this.readLock.lock();
try {
return super.getSeriesPeriodDescriptionInSystemTimeZone();
} finally {
this.readLock.unlock();
}
}
}
@@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.ArrayList;
import java.util.List;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
/**
* Builder for {@link ConcurrentBarSeries} instances.
*
* @since 0.22.2
*/
public class ConcurrentBarSeriesBuilder implements BarSeriesBuilder {
private static final String UNNAMED_SERIES_NAME = "unnamed_series";
private List<Bar> bars;
private String name;
private boolean maxBarCountConfigured;
private int maxBarCount;
private NumFactory numFactory = DecimalNumFactory.getInstance();
private BarBuilderFactory barBuilderFactory = new TimeBarBuilderFactory(true);
/**
* Creates a builder for {@link ConcurrentBarSeries}.
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder() {
initValues();
}
private void initValues() {
this.bars = new ArrayList<>();
this.name = UNNAMED_SERIES_NAME;
this.maxBarCountConfigured = false;
this.maxBarCount = Integer.MAX_VALUE;
}
/**
* {@inheritDoc}
*
* @since 0.22.2
*/
@Override
public ConcurrentBarSeries build() {
int beginIndex = -1;
int endIndex = -1;
if (!bars.isEmpty()) {
beginIndex = 0;
endIndex = bars.size() - 1;
}
// If maxBarCount is configured, the series must be unconstrained to allow
// removals.
boolean effectiveConstrained = !maxBarCountConfigured;
var series = new ConcurrentBarSeries(name == null ? UNNAMED_SERIES_NAME : name, bars, beginIndex, endIndex,
effectiveConstrained, numFactory, barBuilderFactory);
if (maxBarCountConfigured) {
series.setMaximumBarCount(maxBarCount);
}
initValues();
return series;
}
/**
* @param numFactory {@link NumFactory} to back the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withNumFactory(NumFactory numFactory) {
this.numFactory = numFactory;
return this;
}
/**
* @param name name of the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withName(String name) {
this.name = name;
return this;
}
/**
* @param bars initial bars for the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withBars(List<Bar> bars) {
this.bars = new ArrayList<>(bars);
return this;
}
/**
* @param maxBarCount maximum retained bars (also opts the series into
* pruning/unconstrained mode)
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withMaxBarCount(int maxBarCount) {
this.maxBarCount = maxBarCount;
this.maxBarCountConfigured = true;
return this;
}
/**
* @param barBuilderFactory builder factory for bars
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withBarBuilderFactory(BarBuilderFactory barBuilderFactory) {
this.barBuilderFactory = barBuilderFactory;
return this;
}
}
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.Instant;
import org.ta4j.core.num.Num;
/**
* Deprecated live-fill compatibility contract.
*
* <p>
* Use {@link TradeFill} for new code. This interface remains available in the
* 0.22.x line so existing live adapters can migrate without a hard compile
* break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public interface ExecutionFill extends Serializable {
/**
* @return the execution timestamp (UTC)
* @since 0.22.2
*/
Instant time();
/**
* @return the execution price per asset
* @since 0.22.2
*/
Num price();
/**
* @return the execution amount
* @since 0.22.2
*/
Num amount();
/**
* @return the execution fee (nullable, zero when unknown)
* @since 0.22.2
*/
Num fee();
/**
* @return the execution side
* @since 0.22.2
*/
ExecutionSide side();
/**
* @return the exchange order id if available
* @since 0.22.2
*/
String orderId();
/**
* @return the correlation id if available
* @since 0.22.2
*/
String correlationId();
/**
* @return the associated intent id, defaulting to {@link #correlationId()}
* @since 0.22.2
*/
default String intentId() {
return correlationId();
}
/**
* @return the bar index for the fill, or {@code -1} when not specified
* @since 0.22.2
*/
default int index() {
return -1;
}
/**
* @return true when the fill has a non-zero fee
* @since 0.22.2
*/
default boolean hasFee() {
Num fee = fee();
return fee != null && !fee.isZero();
}
}
@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
/**
* Generic execution intent metadata for live trading systems.
*
* <p>
* This is intentionally minimal so adapters can map their decision objects
* without leaking platform-specific details into ta4j core.
* </p>
*
* @param intentId unique intent identifier
* @param side execution side
* @param createdAt intent creation timestamp (UTC)
* @param correlationId optional correlation id for external systems
* @since 0.22.2
*/
public record ExecutionIntent(String intentId, ExecutionSide side, Instant createdAt,
String correlationId) implements Serializable {
@Serial
private static final long serialVersionUID = 8030047863134194008L;
public ExecutionIntent {
if (intentId == null || intentId.isBlank()) {
throw new IllegalArgumentException("intentId must be non-blank");
}
Objects.requireNonNull(side, "side");
Objects.requireNonNull(createdAt, "createdAt");
if (correlationId != null && correlationId.isBlank()) {
throw new IllegalArgumentException("correlationId must be non-blank when provided");
}
}
}
@@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Matching policy for pairing exits against open position lots.
*
* @since 0.22.2
*/
public enum ExecutionMatchPolicy {
/**
* First-in, first-out matching; exits close the earliest open lots.
*
* @since 0.22.2
*/
FIFO,
/**
* Last-in, first-out matching; exits close the most recent open lots.
*
* @since 0.22.2
*/
LIFO,
/**
* Average-cost matching; entries are merged into a single lot and exits use the
* weighted average cost basis.
*
* @since 0.22.2
*/
AVG_COST,
/**
* Match exits to a specific lot using correlationId or orderId; exit amounts
* must not exceed the matched lot.
*
* @since 0.22.2
*/
SPECIFIC_ID
}
@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Execution side for live fills.
*
* @since 0.22.2
*/
public enum ExecutionSide {
/**
* Buy-side execution.
*
* @since 0.22.2
*/
BUY,
/**
* Sell-side execution.
*
* @since 0.22.2
*/
SELL;
/**
* @return the corresponding {@link Trade.TradeType}
* @since 0.22.2
*/
public Trade.TradeType toTradeType() {
return this == BUY ? Trade.TradeType.BUY : Trade.TradeType.SELL;
}
}
@@ -0,0 +1,206 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.num.Num;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.IndicatorSerialization;
import org.ta4j.core.serialization.IndicatorSerializationException;
import java.util.stream.IntStream;
import java.util.stream.Stream;
/**
* Indicator over a {@link BarSeries bar series}.
*
* <p>
* Returns a value of type <b>T</b> for each index of the bar series.
*
* <p>
* Indicators transform price/volume data (or other indicators) into new data
* series (e.g., Moving Averages, RSI). They calculate values lazily or eagerly
* and are the fundamental building blocks for {@link Rule rules}. Values are
* retrieved per-index via {@link #getValue(int)}.
*
* @param <T> the type of the returned value (Double, Boolean, etc.)
*/
public interface Indicator<T> {
/**
* @param index the bar index
* @return the value of the indicator
*/
T getValue(int index);
/**
* Returns {@code true} once {@code this} indicator has enough bars to
* accurately calculate its value. Otherwise, {@code false} will be returned,
* which means the indicator will give incorrect values due to insufficient
* data. This method determines stability using the formula:
*
* <pre>
* isStable = {@link BarSeries#getBarCount()} >= {@link #getCountOfUnstableBars()}
* </pre>
*
* @return true if the calculated indicator value is correct
*/
default boolean isStable() {
return getBarSeries().getBarCount() >= getCountOfUnstableBars();
}
/**
* Returns the number of bars up to which {@code this} Indicator calculates
* wrong values.
*
* @return unstable bars
*/
int getCountOfUnstableBars();
/**
* @return the related bar series
*/
BarSeries getBarSeries();
/**
* @return all values from {@code this} Indicator over {@link #getBarSeries()}
* as a Stream
*/
default Stream<T> stream() {
return IntStream.range(getBarSeries().getBeginIndex(), getBarSeries().getEndIndex() + 1)
.mapToObj(this::getValue);
}
/**
* Returns all values of an {@link Indicator} within the given {@code index} and
* {@code barCount} as an array of Doubles. The returned doubles could have a
* minor loss of precision, if {@link Indicator} was based on {@link Num Num}.
*
* @param ref the indicator
* @param index the index
* @param barCount the barCount
* @return array of Doubles within {@code index} and {@code barCount}
*/
static Double[] toDouble(Indicator<Num> ref, int index, int barCount) {
int startIndex = Math.max(0, index - barCount + 1);
return IntStream.range(startIndex, startIndex + barCount)
.mapToObj(ref::getValue)
.map(Num::doubleValue)
.toArray(Double[]::new);
}
/**
* Serializes {@code this} indicator into a JSON payload that captures its type,
* numeric parameters, and child indicators.
*
* <p>
* The serialization process uses reflection to introspect the indicator's
* structure, extracting numeric constructor parameters and recursively
* serializing child indicators. The resulting JSON can be used to reconstruct
* an equivalent indicator instance using {@link #fromJson(BarSeries, String)}.
*
* <p>
* The JSON format includes:
* <ul>
* <li>The indicator's simple class name (type)</li>
* <li>Numeric parameters extracted from constructor arguments</li>
* <li>Child indicators as nested component descriptors</li>
* </ul>
*
* @return JSON description of the indicator
* @throws IndicatorSerializationException if serialization fails due to
* reflection errors, class loading
* issues, or JSON generation problems.
* This exception wraps all underlying
* serialization failures, providing a
* consistent exception type for error
* handling.
* @since 0.19
*/
default String toJson() {
return IndicatorSerialization.toJson(this);
}
/**
* Converts {@code this} indicator into a structured descriptor that can be
* embedded inside other component metadata.
*
* <p>
* This method creates a {@link ComponentDescriptor} that represents the
* indicator's structure, including its type, numeric parameters, and child
* indicators. The descriptor can be used for serialization, comparison, or
* embedding within larger component hierarchies (such as rules or strategies).
*
* <p>
* The descriptor extraction process:
* <ul>
* <li>Uses reflection to introspect the indicator's fields</li>
* <li>Extracts numeric constructor parameters</li>
* <li>Recursively processes child indicators, handling circular references</li>
* <li>Builds a tree structure representing the indicator's composition</li>
* </ul>
*
* @return component descriptor for the indicator
* @throws IndicatorSerializationException if descriptor creation fails due to
* reflection errors, class loading
* issues, or problems processing
* circular references. This exception
* wraps all underlying failures,
* providing a consistent exception type
* for error handling.
* @since 0.19
*/
default ComponentDescriptor toDescriptor() {
return IndicatorSerialization.describe(this);
}
/**
* Reconstructs an indicator instance from its serialized representation.
*
* <p>
* This method parses a JSON payload (typically generated by {@link #toJson()})
* and reconstructs an equivalent indicator instance. The deserialization
* process:
* <ul>
* <li>Parses the JSON into a component descriptor structure</li>
* <li>Resolves indicator types by simple name from the classpath</li>
* <li>Matches constructor parameters to descriptor values</li>
* <li>Recursively instantiates child indicators</li>
* <li>Constructs the indicator using reflection-based constructor matching</li>
* </ul>
*
* <p>
* <strong>Important:</strong> The indicator type must be resolvable from the
* classpath. Custom indicator classes must be in the
* {@code org.ta4j.core.indicators} package or registered appropriately for
* successful deserialization.
*
* @param series backing series to attach to the reconstructed indicator
* @param json serialized indicator payload generated by {@link #toJson()}
* @return indicator instance
* @throws IndicatorSerializationException if deserialization fails due to:
* <ul>
* <li>Invalid or malformed JSON
* syntax</li>
* <li>Unknown indicator type (class not
* found or not in expected
* package)</li>
* <li>Missing or incompatible
* constructor parameters</li>
* <li>Constructor instantiation
* failures</li>
* <li>Class loading or reflection
* errors</li>
* </ul>
* This exception wraps all underlying
* deserialization failures, providing a
* consistent exception type for error
* handling. The original cause is
* preserved and can be accessed via
* {@link Throwable#getCause()}.
* @since 0.19
*/
static Indicator<?> fromJson(BarSeries series, String json) {
return IndicatorSerialization.fromJson(series, json);
}
}
@@ -0,0 +1,138 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.Serial;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated live-trade compatibility record.
*
* <p>
* Use {@link BaseTrade} and {@link TradeFill} for new code.
* </p>
*
* @param index trade index
* @param time execution timestamp (UTC)
* @param price execution price per asset
* @param amount execution amount
* @param fee execution fee (nullable, defaults to zero)
* @param side execution side (BUY/SELL)
* @param orderId optional order identifier
* @param correlationId optional correlation identifier
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public record LiveTrade(int index, Instant time, Num price, Num amount, Num fee, ExecutionSide side, String orderId,
String correlationId) implements Trade, ExecutionFill {
@Serial
private static final long serialVersionUID = 3196554864123769210L;
private static final Gson GSON = new Gson();
private static final CostModel RECORDED_COST_MODEL = RecordedTradeCostModel.INSTANCE;
public LiveTrade {
DeprecationNotifier.warnOnce(LiveTrade.class, "org.ta4j.core.BaseTrade");
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
Objects.requireNonNull(time, "time");
Objects.requireNonNull(price, "price");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(side, "side");
if (fee == null) {
fee = price.getNumFactory().zero();
}
}
@Override
public Trade.TradeType getType() {
return side.toTradeType();
}
@Override
public int getIndex() {
return index;
}
@Override
public Num getPricePerAsset() {
return price;
}
@Override
public Num getNetPrice() {
if (amount.isZero()) {
return price;
}
Num costPerAsset = fee.dividedBy(amount);
if (side == ExecutionSide.BUY) {
return price.plus(costPerAsset);
}
return price.minus(costPerAsset);
}
@Override
public Num getAmount() {
return amount;
}
@Override
public Num getCost() {
return fee;
}
@Override
public CostModel getCostModel() {
return RECORDED_COST_MODEL;
}
@Override
public Instant getTime() {
return time;
}
@Override
public String getOrderId() {
return orderId;
}
@Override
public String getCorrelationId() {
return correlationId;
}
/**
* Returns a copy of this trade with a new index.
*
* @param index the trade index
* @return a trade with the provided index
* @since 0.22.2
*/
public LiveTrade withIndex(int index) {
return new LiveTrade(index, time, price, amount, fee, side, orderId, correlationId);
}
@Override
public String toString() {
JsonObject json = new JsonObject();
json.addProperty("index", index);
json.addProperty("time", time == null ? null : time.toString());
json.addProperty("price", price == null ? null : price.toString());
json.addProperty("amount", amount == null ? null : amount.toString());
json.addProperty("fee", fee == null ? null : fee.toString());
json.addProperty("side", side == null ? null : side.name());
json.addProperty("orderId", orderId);
json.addProperty("correlationId", correlationId);
return GSON.toJson(json);
}
}
@@ -0,0 +1,161 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated live-trading compatibility facade.
*
* <p>
* Use {@link BaseTradingRecord} for new code. This type remains available in
* the 0.22.x line so existing adapters can migrate without a hard patch-line
* break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public class LiveTradingRecord extends BaseTradingRecord implements PositionLedger {
@Serial
private static final long serialVersionUID = 7960596064337713648L;
/**
* Creates a live trading record with BUY entries and FIFO matching.
*
* @since 0.22.2
*/
public LiveTradingRecord() {
this(TradeType.BUY);
}
/**
* Creates a live trading record.
*
* @param startingType entry trade type
* @since 0.22.2
*/
public LiveTradingRecord(TradeType startingType) {
this(startingType, ExecutionMatchPolicy.FIFO, RecordedTradeCostModel.INSTANCE, new ZeroCostModel(), null, null);
}
/**
* Creates a live trading record.
*
* @param startingType entry trade type
* @param matchPolicy matching policy
* @param transactionCostModel ignored in favor of recorded fees
* @param holdingCostModel holding cost model
* @param startIndex optional start index
* @param endIndex optional end index
* @since 0.22.2
*/
public LiveTradingRecord(TradeType startingType, ExecutionMatchPolicy matchPolicy, CostModel transactionCostModel,
CostModel holdingCostModel, Integer startIndex, Integer endIndex) {
super(startingType, matchPolicy, RecordedTradeCostModel.INSTANCE,
holdingCostModel == null ? new ZeroCostModel() : holdingCostModel, startIndex, endIndex);
warnDeprecated();
}
/**
* Records a live trade using an auto-incremented trade index.
*
* @param trade live trade
* @since 0.22.2
*/
public void recordFill(LiveTrade trade) {
Objects.requireNonNull(trade, "trade");
TradeFill fill = new TradeFill(-1, trade.time(), trade.price(), trade.amount(), trade.fee(), trade.side(),
trade.orderId(), trade.correlationId());
super.operate(Trade.fromFill(fill));
}
/**
* Records a live trade using the provided trade index.
*
* @param index trade index
* @param trade live trade
* @since 0.22.2
*/
public void recordFill(int index, LiveTrade trade) {
Objects.requireNonNull(trade, "trade");
TradeFill fill = new TradeFill(index, trade.time(), trade.price(), trade.amount(), trade.fee(), trade.side(),
trade.orderId(), trade.correlationId());
super.operate(Trade.fromFill(fill));
}
/**
* Records a live fill using the deprecated {@link ExecutionFill} contract.
*
* @param fill execution fill
* @since 0.22.2
*/
public void recordExecutionFill(ExecutionFill fill) {
Objects.requireNonNull(fill, "fill");
ExecutionSide side = resolveExecutionSide(fill.side());
Instant time = fill.time() == null ? Instant.EPOCH : fill.time();
TradeFill tradeFill = new TradeFill(fill.index(), time, fill.price(), fill.amount(), fill.fee(), side,
fill.orderId(), fill.correlationId());
super.operate(Trade.fromFill(tradeFill));
}
/**
* Rehydrates transient cost models after deserialization.
*
* <p>
* Live trading records always use {@link RecordedTradeCostModel} for
* transaction costs.
* </p>
*
* @param holdingCostModel holding cost model, null defaults to
* {@link ZeroCostModel}
* @since 0.22.2
*/
@Override
public void rehydrate(CostModel holdingCostModel) {
rehydrate(RecordedTradeCostModel.INSTANCE, holdingCostModel);
}
/**
* Rehydrates transient cost models after deserialization.
*
* <p>
* Live trading records always use {@link RecordedTradeCostModel} for
* transaction costs. The supplied transaction cost model is ignored.
* </p>
*
* @param transactionCostModel ignored in favor of recorded fees
* @param holdingCostModel holding cost model, null defaults to
* {@link ZeroCostModel}
* @since 0.22.2
*/
@Override
public void rehydrate(CostModel transactionCostModel, CostModel holdingCostModel) {
super.rehydrate(RecordedTradeCostModel.INSTANCE, holdingCostModel);
}
private ExecutionSide resolveExecutionSide(ExecutionSide side) {
if (side != null) {
return side;
}
Position currentPosition = getCurrentPosition();
if (currentPosition == null || !currentPosition.isOpened() || currentPosition.getEntry() == null) {
return getStartingType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
}
return currentPosition.getEntry().isBuy() ? ExecutionSide.SELL : ExecutionSide.BUY;
}
private static void warnDeprecated() {
DeprecationNotifier.warnOnce(LiveTradingRecord.class, "org.ta4j.core.BaseTradingRecord");
}
}
@@ -0,0 +1,559 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import static org.ta4j.core.num.NaN.NaN;
import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* A {@code Position} models either a closed entry/exit pair or an open position
* snapshot with only an entry trade.
*
* <p>
* The exit trade has the complement type of the entry trade, i.e.:
* <ul>
* <li>entry == BUY --> exit == SELL
* <li>entry == SELL --> exit == BUY
* </ul>
*
* <p>
* Open-position inspection APIs on {@link TradingRecord} also use this type, so
* callers can query per-lot and net exposure through one consistent contract.
* </p>
*/
public class Position implements Serializable {
@Serial
private static final long serialVersionUID = -5484709075767220358L;
/** The entry trade */
private Trade entry;
/** The exit trade */
private Trade exit;
/** The type of the entry trade */
private final TradeType startingType;
/** The cost model for transactions of the asset */
private final transient CostModel transactionCostModel;
/** The cost model for holding the asset */
private final transient CostModel holdingCostModel;
/** Constructor with {@link #startingType} = BUY. */
public Position() {
this(TradeType.BUY);
}
/**
* Constructor.
*
* @param startingType the starting {@link TradeType trade type} of the position
* (i.e. type of the entry trade)
*/
public Position(TradeType startingType) {
this(startingType, new ZeroCostModel(), new ZeroCostModel());
}
/**
* Constructor.
*
* @param startingType the starting {@link TradeType trade type} of the
* position (i.e. type of the entry trade)
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
*/
public Position(TradeType startingType, CostModel transactionCostModel, CostModel holdingCostModel) {
if (startingType == null) {
throw new IllegalArgumentException("Starting type must not be null");
}
this.startingType = startingType;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* Constructor.
*
* @param entry the entry {@link Trade trade}
* @param exit the exit {@link Trade trade}
*/
public Position(Trade entry, Trade exit) {
this(entry, exit, entry.getCostModel(), new ZeroCostModel());
}
/**
* Constructor.
*
* @param entry the entry {@link Trade trade}
* @param exit the exit {@link Trade trade}
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
*/
public Position(Trade entry, Trade exit, CostModel transactionCostModel, CostModel holdingCostModel) {
if (entry.getType().equals(exit.getType())) {
throw new IllegalArgumentException("Both trades must have different types");
}
if (!(entry.getCostModel().equals(transactionCostModel))
|| !(exit.getCostModel().equals(transactionCostModel))) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
this.startingType = entry.getType();
this.entry = entry;
this.exit = exit;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* Constructor for an open position.
*
* @param entry the entry {@link Trade trade}
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
* @since 0.22.2
*/
public Position(Trade entry, CostModel transactionCostModel, CostModel holdingCostModel) {
Objects.requireNonNull(entry, "entry");
if (!(entry.getCostModel().equals(transactionCostModel))) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
this.startingType = entry.getType();
this.entry = entry;
this.exit = null;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* @return the entry {@link Trade trade} of the position
*/
public Trade getEntry() {
return entry;
}
/**
* @return the exit {@link Trade trade} of the position
*/
public Trade getExit() {
return exit;
}
/**
* Returns the entry-side direction of this position.
*
* @return the entry side, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public ExecutionSide side() {
if (entry == null) {
return null;
}
return entry.isBuy() ? ExecutionSide.BUY : ExecutionSide.SELL;
}
/**
* Returns the entry amount of this position.
*
* <p>
* For aggregated open positions this is the net open amount.
* </p>
*
* @return the entry amount, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public Num amount() {
return entry == null ? null : entry.getAmount();
}
/**
* Returns the average entry price of this position.
*
* <p>
* For standard positions this is the entry trade price. For aggregated open
* positions this is the weighted average entry price of the net exposure.
* </p>
*
* @return the average entry price, or {@code null} when the position has no
* entry yet
* @since 0.22.4
*/
public Num averageEntryPrice() {
return entry == null ? null : entry.getPricePerAsset();
}
/**
* Returns the total entry cost of this position.
*
* @return the total entry cost, or {@code null} when the position has no entry
* yet
* @since 0.22.4
*/
public Num totalEntryCost() {
return entry == null ? null : entry.getValue();
}
/**
* Returns the entry fees currently carried by this position.
*
* <p>
* For aggregated open positions this reflects the summed remaining entry fees.
* </p>
*
* @return the entry fees, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public Num totalFees() {
return entry == null ? null : entry.getCost();
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Position p) {
return (entry == null ? p.getEntry() == null : entry.equals(p.getEntry()))
&& (exit == null ? p.getExit() == null : exit.equals(p.getExit()));
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(entry, exit);
}
/**
* Operates the position at the index-th position.
*
* @param index the bar index
* @return the trade
* @see #operate(int, Num, Num)
*/
public Trade operate(int index) {
return operate(index, NaN, NaN);
}
/**
* Operates the position at the index-th position.
*
* @param index the bar index
* @param price the price
* @param amount the amount
* @return the trade
* @throws IllegalStateException if {@link #isOpened()} and index {@literal <}
* entry.index
*/
public Trade operate(int index, Num price, Num amount) {
CostModel effectiveTransactionCostModel = getTransactionCostModel();
Trade trade = null;
if (isNew()) {
trade = operate(new BaseTrade(index, startingType, price, amount, effectiveTransactionCostModel));
} else if (isOpened()) {
if (index < entry.getIndex()) {
throw new IllegalStateException("The index i is less than the entryTrade index");
}
trade = operate(
new BaseTrade(index, startingType.complementType(), price, amount, effectiveTransactionCostModel));
}
return trade;
}
/**
* Operates the position with a pre-built trade.
*
* @param trade the trade to apply
* @return the trade
* @since 0.22.4
*/
public Trade operate(Trade trade) {
Objects.requireNonNull(trade, "trade");
CostModel effectiveTransactionCostModel = getTransactionCostModel();
if (!trade.getCostModel().equals(effectiveTransactionCostModel)) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
if (isNew()) {
if (trade.getType() != startingType) {
throw new IllegalArgumentException("The first trade type must match the starting type");
}
entry = trade;
return trade;
}
if (isOpened()) {
if (trade.getType() != startingType.complementType()) {
throw new IllegalArgumentException("The exit trade type must complement the entry trade type");
}
if (trade.getIndex() < entry.getIndex()) {
throw new IllegalStateException("The index i is less than the entryTrade index");
}
exit = trade;
return trade;
}
return null;
}
/**
* @return true if the position is closed, false otherwise
*/
public boolean isClosed() {
return (entry != null) && (exit != null);
}
/**
* @return true if the position is opened, false otherwise
*/
public boolean isOpened() {
return (entry != null) && (exit == null);
}
/**
* @return true if the position is new, false otherwise
*/
public boolean isNew() {
return (entry == null) && (exit == null);
}
/**
* @return true if position is closed and {@link #getProfit()} > 0
*/
public boolean hasProfit() {
return getProfit().isPositive();
}
/**
* @return true if position is closed and {@link #getProfit()} {@literal <} 0
*/
public boolean hasLoss() {
return getProfit().isNegative();
}
/**
* Calculates the net profit of the position if it is closed. The net profit
* includes any trading costs.
*
* @return the profit or loss of the position
*/
public Num getProfit() {
if (isOpened()) {
return zero();
} else {
return getGrossProfit(exit.getPricePerAsset()).minus(getPositionCost());
}
}
/**
* Calculates the net profit of the position. If it is open, calculates the
* profit until the final bar. The net profit includes any trading costs.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the profit or loss of the position
*/
public Num getProfit(int finalIndex, Num finalPrice) {
Num grossProfit = getGrossProfit(finalPrice);
Num tradingCost = getPositionCost(finalIndex);
return grossProfit.minus(tradingCost);
}
/**
* Calculates the gross profit of the position if it is closed. The gross profit
* excludes any trading costs.
*
* @return the gross profit of the position
*/
public Num getGrossProfit() {
if (isOpened()) {
return zero();
} else {
return getGrossProfit(exit.getPricePerAsset());
}
}
/**
* Calculates the gross profit of the position. The gross profit excludes any
* trading costs.
*
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the profit or loss of the position
*/
public Num getGrossProfit(Num finalPrice) {
Num grossProfit;
if (isOpened()) {
grossProfit = entry.getAmount().multipliedBy(finalPrice).minus(entry.getValue());
} else {
grossProfit = exit.getValue().minus(entry.getValue());
}
// Profits of long position are losses of short
if (entry.isSell()) {
grossProfit = grossProfit.negate();
}
return grossProfit;
}
/**
* Calculates the gross return of the position if it is closed. The gross return
* excludes any trading costs (and includes the base).
*
* @return the gross return of the position in percent
* @see #getGrossReturn(Num)
*/
public Num getGrossReturn() {
if (isOpened()) {
return zero();
} else {
return getGrossReturn(exit.getPricePerAsset());
}
}
/**
* Calculates the gross return of the position, if it exited at the provided
* price. The gross return excludes any trading costs (and includes the base).
*
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the gross return of the position in percent
* @see #getGrossReturn(Num, Num)
*/
public Num getGrossReturn(Num finalPrice) {
return getGrossReturn(getEntry().getPricePerAsset(), finalPrice);
}
/**
* Calculates the gross return of the position. If either the entry or exit
* price is {@code NaN}, the close price from given {@code barSeries} is used.
* The gross return excludes any trading costs (and includes the base).
*
* @param barSeries
* @return the gross return in percent with entry and exit prices from the
* barSeries
* @see #getGrossReturn(Num, Num)
*/
public Num getGrossReturn(BarSeries barSeries) {
Num entryPrice = getEntry().getPricePerAsset(barSeries);
Num exitPrice = getExit().getPricePerAsset(barSeries);
return getGrossReturn(entryPrice, exitPrice);
}
/**
* Calculates the gross return between entry and exit price in percent. Includes
* the base.
*
* <p>
* For example:
* <ul>
* <li>For buy position with a profit of 4%, it returns 1.04 (includes the base)
* <li>For sell position with a loss of 4%, it returns 0.96 (includes the base)
* </ul>
*
* @param entryPrice the entry price
* @param exitPrice the exit price
* @return the gross return in percent between entryPrice and exitPrice
* (includes the base)
*/
public Num getGrossReturn(Num entryPrice, Num exitPrice) {
if (getEntry().isBuy()) {
return exitPrice.dividedBy(entryPrice);
} else {
Num one = entryPrice.getNumFactory().one();
return ((exitPrice.dividedBy(entryPrice).minus(one)).negate()).plus(one);
}
}
/**
* Calculates the total cost of the position.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @return the cost of the position
*/
public Num getPositionCost(int finalIndex) {
Num transactionCost = transactionCostModel.calculate(this, finalIndex);
Num borrowingCost = getHoldingCost(finalIndex);
return transactionCost.plus(borrowingCost);
}
/**
* Calculates the total cost of the closed position.
*
* @return the cost of the position
*/
public Num getPositionCost() {
Num transactionCost = transactionCostModel.calculate(this);
Num borrowingCost = getHoldingCost();
return transactionCost.plus(borrowingCost);
}
/**
* Calculates the holding cost of the closed position.
*
* @return the cost of the position
*/
public Num getHoldingCost() {
return holdingCostModel.calculate(this);
}
/**
* Calculates the holding cost of the position.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @return the cost of the position
*/
public Num getHoldingCost(int finalIndex) {
return holdingCostModel.calculate(this, finalIndex);
}
/**
* @return the transaction cost model, or a zero-cost model after
* deserialization when the model is unset
*
* @since 0.22.2
*/
public CostModel getTransactionCostModel() {
return transactionCostModel == null ? new ZeroCostModel() : transactionCostModel;
}
/**
* @return the holding cost model, or a zero-cost model after deserialization
* when the model is unset
*
* @since 0.22.2
*/
public CostModel getHoldingCostModel() {
return holdingCostModel == null ? new ZeroCostModel() : holdingCostModel;
}
/**
* @return the {@link #startingType}
*/
public TradeType getStartingType() {
return startingType;
}
/**
* @return the Num of 0
*/
private Num zero() {
return entry.getNetPrice().getNumFactory().zero();
}
@Override
public String toString() {
return "Entry: " + entry + " exit: " + exit;
}
}
@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.List;
/**
* Deprecated compatibility view of closed and open positions.
*
* <p>
* Use {@link TradingRecord#getPositions()},
* {@link TradingRecord#getOpenPositions()}, and
* {@link TradingRecord#getCurrentPosition()} directly in new code.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public interface PositionLedger {
/**
* @return the recorded closed positions
* @since 0.22.2
*/
List<Position> getPositions();
/**
* @return open positions
* @since 0.22.2
*/
List<Position> getOpenPositions();
/**
* @return the aggregated net open position
* @since 0.22.2
*/
Position getNetOpenPosition();
}
@@ -0,0 +1,151 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.num.Num;
/**
* Extension of {@link Bar} for realtime analytics that track trade side and
* liquidity breakdowns.
*
* <p>
* Side- and liquidity-aware values are optional because exchanges may not
* provide them for every trade. When {@link #hasSideData()} or
* {@link #hasLiquidityData()} is {@code false}, the corresponding getters
* return zero values.
*
* @since 0.22.2
*/
public interface RealtimeBar extends Bar {
/**
* The aggressor side of a trade.
*
* @since 0.22.2
*/
enum Side {
BUY, SELL
}
/**
* The liquidity classification of a trade.
*
* @since 0.22.2
*/
enum Liquidity {
MAKER, TAKER
}
/**
* @return {@code true} if at least one trade with side information was
* aggregated into this bar
*
* @since 0.22.2
*/
boolean hasSideData();
/**
* @return {@code true} if at least one trade with liquidity information was
* aggregated into this bar
*
* @since 0.22.2
*/
boolean hasLiquidityData();
/**
* @return buy-side traded volume
*
* @since 0.22.2
*/
Num getBuyVolume();
/**
* @return sell-side traded volume
*
* @since 0.22.2
*/
Num getSellVolume();
/**
* @return buy-side traded amount
*
* @since 0.22.2
*/
Num getBuyAmount();
/**
* @return sell-side traded amount
*
* @since 0.22.2
*/
Num getSellAmount();
/**
* @return number of buy-side trades
*
* @since 0.22.2
*/
long getBuyTrades();
/**
* @return number of sell-side trades
*
* @since 0.22.2
*/
long getSellTrades();
/**
* @return maker-side traded volume
*
* @since 0.22.2
*/
Num getMakerVolume();
/**
* @return taker-side traded volume
*
* @since 0.22.2
*/
Num getTakerVolume();
/**
* @return maker-side traded amount
*
* @since 0.22.2
*/
Num getMakerAmount();
/**
* @return taker-side traded amount
*
* @since 0.22.2
*/
Num getTakerAmount();
/**
* @return number of maker-side trades
*
* @since 0.22.2
*/
long getMakerTrades();
/**
* @return number of taker-side trades
*
* @since 0.22.2
*/
long getTakerTrades();
/**
* Adds a trade with optional side and liquidity classification.
*
* @param tradeVolume traded volume
* @param tradePrice traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
void addTrade(Num tradeVolume, Num tradePrice, Side side, Liquidity liquidity);
}
@@ -0,0 +1,180 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.rules.AndRule;
import org.ta4j.core.rules.NotRule;
import org.ta4j.core.rules.OrRule;
import org.ta4j.core.rules.XorRule;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.ComponentSerialization;
import org.ta4j.core.serialization.RuleSerialization;
/**
* A rule (also called "trading rule") used to build a {@link Strategy trading
* strategy}. A trading rule can consist of a combination of other rules.
*
* <p>
* Rules encapsulate the logic for trading decisions (e.g., crossing indicators,
* stop-loss thresholds, or boolean conditions). They are evaluated per bar
* index using {@link #isSatisfied(int, TradingRecord)}. Complex logic is built
* by composing primitive rules with logical operators like {@link #and(Rule)},
* {@link #or(Rule)}, or {@link #negation()}.
* </p>
*/
public interface Rule {
/**
* Controls trace logging behavior for rule evaluation.
*
* <p>
* SLF4J TRACE logging is the off switch. This selector only changes the amount
* of detail emitted during an evaluation where the relevant logger is already
* TRACE-enabled.
*
* @since 0.22.7
*/
enum TraceMode {
/**
* Emit one trace event for the evaluated rule while suppressing child-rule
* trace events inside the same scoped evaluation.
*/
SUMMARY,
/** Emit trace logs for this rule and all children in an evaluation scope. */
VERBOSE
}
/**
* Serializes this rule to JSON.
*
* @return JSON representation
* @since 0.22
*/
default String toJson() {
ComponentDescriptor descriptor = RuleSerialization.describe(this);
return ComponentSerialization.toJson(descriptor);
}
/**
* Builds a rule from JSON.
*
* @param series bar series context
* @param json payload
* @return reconstructed rule
* @since 0.22
*/
static Rule fromJson(BarSeries series, String json) {
ComponentDescriptor descriptor = ComponentSerialization.parse(json);
return RuleSerialization.fromDescriptor(series, descriptor);
}
/**
* @param rule another trading rule
* @return a rule which is the AND combination of this rule with the provided
* one
*/
default Rule and(Rule rule) {
return new AndRule(this, rule);
}
/**
* @param rule another trading rule
* @return a rule which is the OR combination of this rule with the provided one
*/
default Rule or(Rule rule) {
return new OrRule(this, rule);
}
/**
* @param rule another trading rule
* @return a rule which is the XOR combination of this rule with the provided
* one
*/
default Rule xor(Rule rule) {
return new XorRule(this, rule);
}
/**
* @return a rule which is the logical negation of this rule
*/
default Rule negation() {
return new NotRule(this);
}
/**
* @param index the bar index
* @return true if this rule is satisfied for the provided index, false
* otherwise
*/
default boolean isSatisfied(int index) {
return isSatisfied(index, (TradingRecord) null);
}
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true if this rule is satisfied for the provided index, false
* otherwise
*/
boolean isSatisfied(int index, TradingRecord tradingRecord);
/**
* Evaluates this rule once with the supplied trace detail. Implementations that
* do not support scoped tracing may ignore {@code traceMode} and delegate to
* {@link #isSatisfied(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link TraceMode#VERBOSE}
* @return true if this rule is satisfied for the provided index, false
* otherwise
* @since 0.22.7
*/
default boolean isSatisfiedWithTraceMode(int index, TraceMode traceMode) {
return isSatisfiedWithTraceMode(index, null, traceMode);
}
/**
* Evaluates this rule once with the supplied trace detail. Implementations that
* do not support scoped tracing may ignore {@code traceMode} and delegate to
* {@link #isSatisfied(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link TraceMode#VERBOSE}
* @return true if this rule is satisfied for the provided index, false
* otherwise
* @since 0.22.7
*/
default boolean isSatisfiedWithTraceMode(int index, TradingRecord tradingRecord, TraceMode traceMode) {
return isSatisfied(index, tradingRecord);
}
/**
* Sets a human friendly name for this rule. Implementations that support naming
* should override this method.
*
* <p>
* The default implementation is a no-op and does not store the name.
*
* @param name desired name; {@code null} or blank should reset the rule name
* back to the implementation specific default
* @since 0.19
*/
default void setName(String name) {
// no-op by default to preserve backwards compatibility for custom Rule
// implementations that do not support naming yet
}
/**
* Returns the configured name for this rule.
*
* @return a descriptive name or a lightweight default
* @since 0.19
*/
default String getName() {
return toString();
}
}
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated simulated-trade compatibility facade.
*
* <p>
* Use {@link BaseTrade} or the static factory methods on {@link Trade} for new
* code. This type remains available in the 0.22.x line so existing backtest
* integrations can migrate without a hard patch-line break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public class SimulatedTrade extends BaseTrade {
@Serial
private static final long serialVersionUID = -905474949010114150L;
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type) {
this(index, series, type, series.numFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type, Num amount) {
this(index, series, type, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution cost
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type, Num amount,
CostModel transactionCostModel) {
super(index, series, type, amount, transactionCostModel);
warnDeprecated();
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset) {
this(index, type, pricePerAsset, pricePerAsset.getNumFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount) {
this(index, type, pricePerAsset, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount,
CostModel transactionCostModel) {
super(index, type, pricePerAsset, amount, transactionCostModel);
warnDeprecated();
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, Trade.TradeType.BUY, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, Num price, Num amount) {
return new SimulatedTrade(index, Trade.TradeType.BUY, price, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series, Num amount) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, Num price, Num amount) {
return new SimulatedTrade(index, Trade.TradeType.SELL, price, amount);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, Trade.TradeType.SELL, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series, Num amount) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL, amount, transactionCostModel);
}
private static void warnDeprecated() {
DeprecationNotifier.warnOnce(SimulatedTrade.class, "org.ta4j.core.BaseTrade");
}
}
@@ -0,0 +1,300 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.StrategySerialization;
/**
* A {@code Strategy} (also called "trading strategy") is a pair of
* complementary (entry and exit) {@link Rule rules}. It may recommend to enter
* or to exit. Recommendations are based respectively on the entry rule or on
* the exit rule.
*
* <p>
* In ta4j, a strategy is evaluated bar by bar during a backtest (via
* {@code BarSeriesManager} or {@code BacktestExecutor}) or in real-time. It
* signals {@code shouldEnter} or {@code shouldExit} based on the current index
* and trading record. Strategies can be composed using logical operators like
* {@link #and(Strategy)} or {@link #or(Strategy)}.
* </p>
*
* <p>
* Live-evaluation reminder: ta4j evaluates whatever bar state you provide at
* {@code index}. If your integration replaces the last bar as new ticks arrive,
* strategy decisions are evaluated against that still-forming bar (live
* candle). If your integration evaluates only after appending a finished bar,
* decisions are based on closed candles.
* </p>
*/
public interface Strategy {
/**
* @return the name of the strategy
*/
String getName();
/**
* @return the entry rule
*/
Rule getEntryRule();
/**
* Returns the starting trade type for this strategy.
*
* <p>
* Defaults to {@link TradeType#BUY} (long-only). Override when the strategy is
* meant to run in short-only mode.
* </p>
*
* @return starting trade type
* @since 0.22.2
*/
default TradeType getStartingType() {
return TradeType.BUY;
}
/**
* @return the exit rule
*/
Rule getExitRule();
/**
* @param strategy the other strategy
* @return the AND combination of two {@link Strategy strategies}
*/
Strategy and(Strategy strategy);
/**
* @param strategy the other strategy
* @return the OR combination of two {@link Strategy strategies}
*/
Strategy or(Strategy strategy);
/**
* @param name the name of the strategy
* @param strategy the other strategy
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
* @return the AND combination of two {@link Strategy strategies}
*/
Strategy and(String name, Strategy strategy, int unstableBars);
/**
* @param name the name of the strategy
* @param strategy the other strategy
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
* @return the OR combination of two {@link Strategy strategies}
*/
Strategy or(String name, Strategy strategy, int unstableBars);
/**
* @return the opposite of the {@link Strategy strategy}
*/
Strategy opposite();
/**
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
*/
void setUnstableBars(int unstableBars);
/**
* @return unstableBars the number of first bars in a bar series that this
* strategy ignores
*/
int getUnstableBars();
/**
* @param index a bar index
* @return true if this strategy is unstable at the provided index, false
* otherwise (stable)
*/
boolean isUnstableAt(int index);
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend a trade, false otherwise (no recommendation)
*/
default boolean shouldOperate(int index, TradingRecord tradingRecord) {
Position position = tradingRecord.getCurrentPosition();
if (position.isNew()) {
return shouldEnter(index, tradingRecord);
} else if (position.isOpened()) {
return shouldExit(index, tradingRecord);
}
return false;
}
/**
* <p>
* <b>Implementation note:</b> This overload ignores trading state. In live
* execution, prefer {@link #shouldEnter(int, TradingRecord)} so rules can see
* open positions and avoid repeated entry signals while a position is already
* open.
* </p>
*
* @param index the bar index
* @return true to recommend to enter, false otherwise
*/
default boolean shouldEnter(int index) {
return shouldEnter(index, (TradingRecord) null);
}
/**
* <p>
* <b>Implementation note:</b> Use this overload for live systems so entry
* decisions include the current position state. After broker-confirmed fills,
* keep {@code tradingRecord} synchronized with executed fills.
* </p>
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend to enter, false otherwise
*/
default boolean shouldEnter(int index, TradingRecord tradingRecord) {
return !isUnstableAt(index) && getEntryRule().isSatisfied(index, tradingRecord);
}
/**
* Evaluates the entry rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldEnter(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to enter, false otherwise
* @since 0.22.7
*/
default boolean shouldEnterWithTraceMode(int index, Rule.TraceMode traceMode) {
return shouldEnterWithTraceMode(index, null, traceMode);
}
/**
* Evaluates the entry rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldEnter(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to enter, false otherwise
* @since 0.22.7
*/
default boolean shouldEnterWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return shouldEnter(index, tradingRecord);
}
/**
* @param index the bar index
* @return true to recommend to exit, false otherwise
*/
default boolean shouldExit(int index) {
return shouldExit(index, (TradingRecord) null);
}
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend to exit, false otherwise
*/
default boolean shouldExit(int index, TradingRecord tradingRecord) {
return !isUnstableAt(index) && getExitRule().isSatisfied(index, tradingRecord);
}
/**
* Evaluates the exit rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldExit(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to exit, false otherwise
* @since 0.22.7
*/
default boolean shouldExitWithTraceMode(int index, Rule.TraceMode traceMode) {
return shouldExitWithTraceMode(index, null, traceMode);
}
/**
* Evaluates the exit rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldExit(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to exit, false otherwise
* @since 0.22.7
*/
default boolean shouldExitWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return shouldExit(index, tradingRecord);
}
/**
* Serializes {@code this} strategy into a JSON payload that captures its
* metadata and rule descriptors.
*
* @return JSON description of the strategy
* @throws NullPointerException if the strategy or any of its components are
* {@code null}
* @throws IllegalStateException if serialization fails due to an internal error
* during JSON generation
* @throws RuntimeException if serialization fails due to an I/O error or
* other runtime exception during JSON processing
* @since 0.19
*/
default String toJson() {
return StrategySerialization.toJson(this);
}
/**
* Converts {@code this} strategy into a structured descriptor that can be
* embedded inside other component metadata.
*
* @return component descriptor for the strategy
* @throws NullPointerException if the strategy or any of its rules are
* {@code null}
* @throws IllegalArgumentException if rule serialization fails due to
* unsupported constructor signatures or
* invalid rule configurations
* @since 0.19
*/
default ComponentDescriptor toDescriptor() {
return StrategySerialization.describe(this);
}
/**
* Reconstructs a strategy instance from its serialized representation.
*
* @param series backing series to attach to the reconstructed strategy; must
* not be {@code null}
* @param json serialized strategy payload generated by {@link #toJson()};
* must not be {@code null} and must be a valid JSON
* representation of a strategy descriptor
* @return reconstructed strategy instance with entry and exit rules restored
* from the descriptor
* @throws NullPointerException if {@code series} or {@code json} is
* {@code null}
* @throws IllegalArgumentException if the JSON payload is malformed, missing
* required component descriptors (entry/exit
* rules), contains incompatible component
* types, or fails validation during
* deserialization
* @throws IllegalStateException if strategy construction fails due to
* missing constructors, instantiation errors,
* or unresolved component dependencies
* @since 0.19
*/
static Strategy fromJson(BarSeries series, String json) {
return StrategySerialization.fromJson(series, json);
}
}
@@ -0,0 +1,425 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* Read-only trade contract shared by simulated and live executions.
*
* <ul>
* <li>the index (in the {@link BarSeries bar series}) on which the trade is
* executed</li>
* <li>a {@link TradeType type} (BUY or SELL)</li>
* <li>a price per asset (optional)</li>
* <li>a trade amount (optional)</li>
* </ul>
*
* <p>
* Metadata fields (timestamp, instrument, ids) are optional and may return
* {@code null}. They loosely mirror the attributes in XChange's trade DTO so
* adapters can preserve exchange-provided identifiers when available.
* </p>
*
* <p>
* Use the static factory methods on {@link Trade} for new code. The concrete
* implementation type is an internal detail.
* </p>
*
* @since 0.22.2
*/
public interface Trade extends Serializable {
/** The type of a trade. */
enum TradeType {
/** A BUY corresponds to a <i>BID</i> trade. */
BUY {
@Override
public TradeType complementType() {
return SELL;
}
},
/** A SELL corresponds to an <i>ASK</i> trade. */
SELL {
@Override
public TradeType complementType() {
return BUY;
}
};
/**
* @return the complementary trade type
*/
public abstract TradeType complementType();
}
/**
* @return the trade type (BUY or SELL)
*/
TradeType getType();
/**
* @return the index the trade is executed
*/
int getIndex();
/**
* @return the trade price per asset
*/
Num getPricePerAsset();
/**
* @param barSeries the bar series
* @return the trade price per asset, or, if {@code NaN}, the close price from
* the supplied {@link BarSeries}
*/
default Num getPricePerAsset(BarSeries barSeries) {
Num price = getPricePerAsset();
if (price.isNaN()) {
return barSeries.getBar(getIndex()).getClosePrice();
}
return price;
}
/**
* @return the net price per asset for the trade (i.e.
* {@link #getPricePerAsset()} with trading costs)
*/
Num getNetPrice();
/**
* @return the trade amount
*/
Num getAmount();
/**
* @return the simulated costs of the trade as calculated by the configured
* {@link CostModel}
*/
Num getCost();
/**
* @return the cost model for trade execution
*/
CostModel getCostModel();
/**
* @return execution timestamp if available, otherwise {@code null}
* @since 0.22.2
*/
default Instant getTime() {
return null;
}
/**
* @return exchange-provided trade id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getId() {
return null;
}
/**
* @return instrument identifier (symbol/pair) if available, otherwise
* {@code null}
* @since 0.22.2
*/
default String getInstrument() {
return null;
}
/**
* @return originating order id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getOrderId() {
return null;
}
/**
* @return correlation id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getCorrelationId() {
return null;
}
/**
* @return true if this is a BUY trade, false otherwise
*/
default boolean isBuy() {
return getType() == TradeType.BUY;
}
/**
* @return true if this is a SELL trade, false otherwise
*/
default boolean isSell() {
return getType() == TradeType.SELL;
}
/**
* @return the value of a trade (without transaction cost)
*/
default Num getValue() {
return getPricePerAsset().multipliedBy(getAmount());
}
/**
* Returns execution fills for this trade.
*
* <p>
* Default simulated trades expose a single fill. Aggregated/partial trades may
* return multiple fills. The default single fill mirrors trade-level metadata
* (time, fee, order/correlation ids) when available.
* </p>
*
* @return execution fills of this trade
* @since 0.22.4
*/
default List<TradeFill> getFills() {
ExecutionSide side = getType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
return List.of(new TradeFill(getIndex(), getTime(), getPricePerAsset(), getAmount(), getCost(), side,
getOrderId(), getCorrelationId()));
}
/**
* Resolves execution fills for the provided trade.
*
* <p>
* Trades should expose fills via {@link #getFills()}. When an implementation
* returns an empty list, this method falls back to index/price/amount to
* preserve compatibility with legacy scalar trade semantics.
* </p>
*
* @param trade trade to inspect
* @return immutable execution fills for the trade
* @since 0.22.4
*/
static List<TradeFill> executionFillsOf(Trade trade) {
Objects.requireNonNull(trade, "trade");
List<TradeFill> fills = List.copyOf(trade.getFills());
if (!fills.isEmpty()) {
return fills;
}
ExecutionSide side = trade.getType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
return List.of(new TradeFill(trade.getIndex(), trade.getTime(), trade.getPricePerAsset(), trade.getAmount(),
trade.getCost(), side, trade.getOrderId(), trade.getCorrelationId()));
}
/**
* Creates a trade from one execution fill using recorded-fee semantics.
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at construction time.
* </p>
*
* @param fill execution fill
* @return a trade representing the provided fill
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
static Trade fromFill(TradeFill fill) {
return fromFill(fill, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one execution fill using an explicit cost model.
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at construction time.
* </p>
*
* @param fill execution fill
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fill
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
static Trade fromFill(TradeFill fill, CostModel transactionCostModel) {
Objects.requireNonNull(fill, "fill");
if (fill.side() == null) {
throw new IllegalArgumentException("fill.side must be set when trade type is not provided");
}
return fromFill(fill.side().toTradeType(), fill, transactionCostModel);
}
/**
* Creates a trade from one execution fill using recorded-fee semantics.
*
* @param type trade type
* @param fill execution fill
* @return a trade representing the provided fill
* @since 0.22.4
*/
static Trade fromFill(TradeType type, TradeFill fill) {
return fromFill(type, fill, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one execution fill.
*
* @param type trade type
* @param fill execution fill
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fill
* @since 0.22.4
*/
static Trade fromFill(TradeType type, TradeFill fill, CostModel transactionCostModel) {
Objects.requireNonNull(fill, "fill");
return fromFills(type, List.of(fill), transactionCostModel);
}
/**
* Creates a trade from one or more execution fills using recorded-fee
* semantics.
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @return a trade representing the provided fills
* @since 0.22.4
*/
static Trade fromFills(TradeType type, List<TradeFill> fills) {
return fromFills(type, fills, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one or more execution fills.
*
* <p>
* The returned trade is a {@link BaseTrade}. Single-fill inputs keep scalar
* semantics; multi-fill inputs preserve full fill progression while exposing
* aggregated price/amount views.
* </p>
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fills
* @throws IllegalArgumentException when fills are empty or invalid
* @since 0.22.4
*/
static Trade fromFills(TradeType type, List<TradeFill> fills, CostModel transactionCostModel) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(fills, "fills");
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
if (fills.isEmpty()) {
throw new IllegalArgumentException("fills must not be empty");
}
return new BaseTrade(type, fills, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series) {
return new BaseTrade(index, series, TradeType.BUY);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
*/
static Trade buyAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, TradeType.BUY, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a BUY trade
*/
static Trade buyAt(int index, Num price, Num amount) {
return new BaseTrade(index, TradeType.BUY, price, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series, Num amount) {
return new BaseTrade(index, series, TradeType.BUY, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, series, TradeType.BUY, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series) {
return new BaseTrade(index, series, TradeType.SELL);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a SELL trade
*/
static Trade sellAt(int index, Num price, Num amount) {
return new BaseTrade(index, TradeType.SELL, price, amount);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
*/
static Trade sellAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, TradeType.SELL, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series, Num amount) {
return new BaseTrade(index, series, TradeType.SELL, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, series, TradeType.SELL, amount, transactionCostModel);
}
}
@@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
/**
* Immutable execution fill used to represent partial trade executions.
*
* <p>
* This is the preferred fill primitive for new integrations that need
* broker-confirmed execution recording through
* {@link TradingRecord#operate(TradeFill)}.
* </p>
*
* <p>
* Metadata fields ({@code time}, {@code side}, IDs) are optional and may be
* {@code null}. {@code fee} defaults to zero when omitted.
* </p>
*
* @param index bar index where the fill happened; {@code -1} is
* reserved for fills awaiting recorder-assigned indices
* @param time optional execution timestamp (UTC)
* @param price execution price per asset
* @param amount executed amount
* @param fee optional execution fee (defaults to zero when null)
* @param side optional execution side
* @param orderId optional order id
* @param correlationId optional correlation id
*
* @since 0.22.4
*/
public record TradeFill(int index, Instant time, Num price, Num amount, Num fee, ExecutionSide side, String orderId,
String correlationId) implements Serializable {
@Serial
private static final long serialVersionUID = -258216480640174496L;
/**
* Creates a trade fill with scalar fields only.
*
* @param index bar index where the fill happened
* @param price execution price per asset
* @param amount executed amount
* @since 0.22.4
*/
public TradeFill(int index, Num price, Num amount) {
this(index, null, price, amount, null, null, null, null);
}
/**
* Creates a trade fill with execution side/time metadata.
*
* @param index bar index where the fill happened
* @param time execution timestamp (UTC), nullable
* @param price execution price per asset
* @param amount executed amount
* @param side execution side, nullable
* @since 0.22.4
*/
public TradeFill(int index, Instant time, Num price, Num amount, ExecutionSide side) {
this(index, time, price, amount, null, side, null, null);
}
/**
* Creates a trade fill.
*
* @throws NullPointerException if price or amount is null
* @since 0.22.4
*/
public TradeFill {
if (index < -1) {
throw new IllegalArgumentException("index must be >= -1");
}
Objects.requireNonNull(price, "price");
Objects.requireNonNull(amount, "amount");
if (fee == null) {
fee = price.getNumFactory().zero();
}
}
}
@@ -0,0 +1,385 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import static org.ta4j.core.num.NaN.NaN;
import java.io.Serializable;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.num.Num;
/**
* A {@code TradingRecord} holds the full history/record of a trading session
* when running a {@link Strategy strategy}. It can be used to:
*
* <ul>
* <li>analyze the performance of a {@link Strategy strategy}
* <li>check whether some {@link Rule rules} are satisfied (while running a
* strategy)
* </ul>
*
* <p>
* {@link Trade} is the public trade contract. Concrete trade implementations
* are internal details and should not be required by strategy or backtest code.
* </p>
*
* <p>
* Execution metadata on trades/fills ({@code time}, {@code side},
* {@code orderId}, {@code correlationId}) may be missing in simulated
* environments. Implementations should preserve this metadata when provided and
* apply deterministic fallbacks when it is absent.
* </p>
*/
public interface TradingRecord extends Serializable {
/**
* @return the entry type (BUY or SELL) of the first trade in the trading
* session
*/
TradeType getStartingType();
/**
* @return the name of the TradingRecord
*/
String getName();
/**
* Places a trade in the trading record.
*
* @param index the index to place the trade
*/
default void operate(int index) {
operate(index, NaN, NaN);
}
/**
* Places a trade in the trading record.
*
* @param index the index to place the trade
* @param price the trade price per asset
* @param amount the trade amount
*/
void operate(int index, Num price, Num amount);
/**
* Places one execution fill in the trading record.
*
* <p>
* This is a convenience overload for streaming partial fills directly into a
* fill-aware record. It is equivalent to {@code operate(Trade.fromFill(fill))}.
* </p>
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at ingestion time.
* </p>
*
* @param fill the execution fill to place
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
default void operate(TradeFill fill) {
operate(Trade.fromFill(fill));
}
/**
* Places a pre-built trade in the trading record.
*
* <p>
* This is useful for execution models that aggregate partial fills into a
* single entry or exit trade.
* </p>
*
* <p>
* The default implementation delegates to {@link #operate(int, Num, Num)} and
* therefore supports only index/price/amount semantics. Implementations that
* store additional execution metadata should override this method.
* </p>
*
* @param trade the trade to place
* @throws UnsupportedOperationException if {@code trade} contains multiple
* fills and this implementation has not
* overridden this method
* @since 0.22.4
*/
default void operate(Trade trade) {
Objects.requireNonNull(trade, "trade");
List<TradeFill> fills = Trade.executionFillsOf(trade);
if (fills.size() > 1) {
throw new UnsupportedOperationException(
"This TradingRecord implementation must override operate(Trade) to preserve multi-fill trades");
}
TradeFill fill = fills.getFirst();
operate(fill.index(), fill.price(), fill.amount());
}
/**
* Places an entry trade in the trading record.
*
* @param index the index to place the entry
* @return true if the entry has been placed, false otherwise
*/
default boolean enter(int index) {
return enter(index, NaN, NaN);
}
/**
* Places an entry trade in the trading record.
*
* @param index the index to place the entry
* @param price the trade price per asset
* @param amount the trade amount
* @return true if the entry has been placed, false otherwise
*/
boolean enter(int index, Num price, Num amount);
/**
* Places an entry trade in the trading record.
*
* @param trade the entry trade to place
* @return true if the entry has been placed, false otherwise
* @throws IllegalArgumentException when trade type is not the configured entry
* type
* @since 0.22.4
*/
default boolean enter(Trade trade) {
Objects.requireNonNull(trade, "trade");
TradeType expectedEntryType = getStartingType();
if (trade.getType() != expectedEntryType) {
throw new IllegalArgumentException("Entry trade type must be " + expectedEntryType);
}
if (isClosed()) {
operate(trade);
return true;
}
return false;
}
/**
* Places an exit trade in the trading record.
*
* @param index the index to place the exit
* @return true if the exit has been placed, false otherwise
*/
default boolean exit(int index) {
return exit(index, NaN, NaN);
}
/**
* Places an exit trade in the trading record.
*
* @param index the index to place the exit
* @param price the trade price per asset
* @param amount the trade amount
* @return true if the exit has been placed, false otherwise
*/
boolean exit(int index, Num price, Num amount);
/**
* Places an exit trade in the trading record.
*
* @param trade the exit trade to place
* @return true if the exit has been placed, false otherwise
* @throws IllegalArgumentException when trade type is not the configured exit
* type
* @since 0.22.4
*/
default boolean exit(Trade trade) {
Objects.requireNonNull(trade, "trade");
TradeType expectedExitType = getStartingType().complementType();
if (trade.getType() != expectedExitType) {
throw new IllegalArgumentException("Exit trade type must be " + expectedExitType);
}
if (!isClosed()) {
operate(trade);
return true;
}
return false;
}
/**
* @return true if no position is open, false otherwise
*/
default boolean isClosed() {
return !getCurrentPosition().isOpened();
}
/**
* @return the transaction cost model
*/
CostModel getTransactionCostModel();
/**
* @return holding cost model
*/
CostModel getHoldingCostModel();
/**
* @return the recorded closed positions
*/
List<Position> getPositions();
/**
* @return the number of recorded closed positions
*/
default int getPositionCount() {
return getPositions().size();
}
/**
* Returns the canonical current-position view for this record.
*
* <p>
* When the record has open exposure, this is the aggregated net-open
* {@link Position}. When the record is flat, this returns a new/empty
* {@link Position} snapshot instead of {@code null}.
* </p>
*
* @return the canonical current-position view
*/
Position getCurrentPosition();
/**
* Returns recorded execution fees when the implementation maintains a fee
* ledger separate from modeled transaction costs.
*
* <p>
* Legacy trading-record implementations can rely on this default and return
* {@code null} when they only support model-derived transaction costs.
* </p>
*
* @return recorded execution fees, or {@code null} when unavailable
* @since 0.22.4
*/
default Num getRecordedTotalFees() {
return null;
}
/**
* Returns open positions when supported by the implementation.
*
* <p>
* Lot-aware implementations should return one open {@link Position} snapshot
* per remaining open lot. Legacy trading-record implementations that only model
* a single synthetic current position can rely on this default and return an
* empty list.
* </p>
*
* @return open per-lot position snapshots
* @since 0.22.4
*/
default List<Position> getOpenPositions() {
return List.of();
}
/**
* Returns the aggregated net open position when supported by the
* implementation.
*
* <p>
* New code should prefer {@link #getCurrentPosition()}. This method remains as
* a compatibility alias for callers that previously requested a dedicated open
* position view. Legacy implementations that do not expose a distinct net-open
* snapshot can rely on the default and return {@code null}.
* </p>
*
* @return aggregated net open position, or {@code null} when no position is
* open
* @since 0.22.4
*/
@Deprecated(since = "0.22.4")
default Position getNetOpenPosition() {
return null;
}
/**
* @return the last closed position recorded
*/
default Position getLastPosition() {
List<Position> positions = getPositions();
if (!positions.isEmpty()) {
return positions.getLast();
}
return null;
}
/**
* @return the trades recorded
*/
List<Trade> getTrades();
/**
* @return the last trade recorded
*/
default Trade getLastTrade() {
List<Trade> trades = getTrades();
if (!trades.isEmpty()) {
return trades.getLast();
}
return null;
}
/**
* @param tradeType the type of the trade to get the last of
* @return the last trade (of the provided type) recorded
*/
default Trade getLastTrade(TradeType tradeType) {
List<Trade> trades = getTrades();
for (int i = trades.size() - 1; i >= 0; i--) {
Trade trade = trades.get(i);
if (trade.getType() == tradeType) {
return trade;
}
}
return null;
}
/**
* @return the last entry trade recorded
*/
default Trade getLastEntry() {
return getLastTrade(getStartingType());
}
/**
* @return the last exit trade recorded
*/
default Trade getLastExit() {
return getLastTrade(getStartingType().complementType());
}
/**
* @return the start of the recording (included)
*/
Integer getStartIndex();
/**
* @return the end of the recording (included)
*/
Integer getEndIndex();
/**
* @param series the bar series, not null
* @return the {@link #getStartIndex()} if not null and greater than
* {@link BarSeries#getBeginIndex()}, otherwise
* {@link BarSeries#getBeginIndex()}
*/
default int getStartIndex(BarSeries series) {
return getStartIndex() == null ? series.getBeginIndex() : Math.max(getStartIndex(), series.getBeginIndex());
}
/**
* @param series the bar series, not null
* @return the {@link #getEndIndex()} if not null and less than
* {@link BarSeries#getEndIndex()}, otherwise
* {@link BarSeries#getEndIndex()}
*/
default int getEndIndex(BarSeries series) {
return getEndIndex() == null ? series.getEndIndex() : Math.min(getEndIndex(), series.getEndIndex());
}
}
@@ -0,0 +1,138 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
/**
* Aggregates a list of {@link Bar bars} into another one.
*/
public interface BarAggregator {
/**
* Aggregates the {@code bars} into another one.
*
* @param bars the bars to be aggregated
* @return aggregated bars
*/
List<Bar> aggregate(List<Bar> bars);
/**
* Validates that source bars are contiguous and have a consistent positive
* interval.
*
* @param bars source bars in chronological order
* @return the common source interval
* @throws NullPointerException if {@code bars} is {@code null}
* @throws IllegalArgumentException if any bar is null, has an invalid period, a
* measured-period mismatch, an uneven
* interval, or a gap between intervals
*
* @since 0.22.4
*/
default Duration requireEvenIntervals(List<Bar> bars) {
Objects.requireNonNull(bars, "bars");
if (bars.isEmpty()) {
return Duration.ZERO;
}
String aggregatorName = getClass().getSimpleName();
Bar firstBar = bars.getFirst();
Duration expectedTimePeriod = requireConsistentPeriod(firstBar, aggregatorName, 0);
Bar previousBar = firstBar;
for (int i = 1; i < bars.size(); i++) {
Bar currentBar = bars.get(i);
Duration currentTimePeriod = requireConsistentPeriod(currentBar, aggregatorName, i);
if (!expectedTimePeriod.equals(currentTimePeriod)) {
throw new IllegalArgumentException(
String.format("%s requires even source intervals: bar %d has period %s but expected %s.",
aggregatorName, i, currentTimePeriod, expectedTimePeriod));
}
if (!currentBar.getBeginTime().equals(previousBar.getEndTime())) {
throw new IllegalArgumentException(String.format(
"%s requires contiguous source intervals: bar %d begins at %s but previous bar ended at %s.",
aggregatorName, i, currentBar.getBeginTime(), previousBar.getEndTime()));
}
previousBar = currentBar;
}
return expectedTimePeriod;
}
/**
* Validates that a numeric threshold/configuration value is finite and strictly
* positive.
*
* @param value the candidate numeric value
* @param parameterName the constructor/argument name
* @return {@code value} when validation succeeds
* @throws NullPointerException if {@code value} is {@code null}
* @throws IllegalArgumentException if {@code value} is non-finite, not
* decimal-representable, or not greater than
* zero
*
* @since 0.22.4
*/
static Number requirePositiveFiniteNumber(Number value, String parameterName) {
Objects.requireNonNull(value, parameterName);
ensureFiniteNumber(value, parameterName);
BigDecimal decimal = parseDecimal(value, parameterName);
if (decimal.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(parameterName + " must be greater than zero.");
}
return value;
}
private static Duration requireConsistentPeriod(Bar bar, String aggregatorName, int index) {
if (bar == null) {
throw new IllegalArgumentException(
String.format("%s requires non-null source bars: bar %d is null.", aggregatorName, index));
}
Duration timePeriod = bar.getTimePeriod();
if (timePeriod == null || timePeriod.isNegative() || timePeriod.isZero()) {
throw new IllegalArgumentException(
String.format("%s requires positive source intervals: bar %d has invalid period %s.",
aggregatorName, index, timePeriod));
}
Instant beginTime = bar.getBeginTime();
Instant endTime = bar.getEndTime();
if (beginTime == null || endTime == null) {
throw new IllegalArgumentException(
String.format("%s requires non-null source timestamps: bar %d has begin=%s, end=%s.",
aggregatorName, index, beginTime, endTime));
}
Duration measuredPeriod = Duration.between(beginTime, endTime);
if (!measuredPeriod.equals(timePeriod)) {
throw new IllegalArgumentException(
String.format("%s requires consistent source intervals: bar %d spans %s but period is %s.",
aggregatorName, index, measuredPeriod, timePeriod));
}
return timePeriod;
}
private static BigDecimal parseDecimal(Number value, String parameterName) {
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException ex) {
throw new IllegalArgumentException(
parameterName + " must be a finite numeric value representable as decimal.", ex);
}
}
private static void ensureFiniteNumber(Number value, String parameterName) {
if (value instanceof Double d && !Double.isFinite(d)) {
throw new IllegalArgumentException(parameterName + " must be finite.");
}
if (value instanceof Float f && !Float.isFinite(f)) {
throw new IllegalArgumentException(parameterName + " must be finite.");
}
}
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import org.ta4j.core.BarSeries;
/**
* Aggregates a {@link BarSeries} into another one.
*/
public interface BarSeriesAggregator {
/**
* Aggregates the {@code series} into another one.
*
* @param series the series to be aggregated
* @return aggregated series
*/
default BarSeries aggregate(BarSeries series) {
return aggregate(series, series.getName());
}
/**
* Aggregates the {@code series} into another one.
*
* @param series the series to be aggregated
* @param aggregatedSeriesName the name for the aggregated series
* @return aggregated series with specified name
*/
BarSeries aggregate(BarSeries series, String aggregatedSeriesName);
}

Some files were not shown because too many files have changed in this diff Show More