FCS Trading Chart

FCS Trading Chart is the FCS Advanced Chart with trading added: an order form plus positions, orders, history and account panels, connected to your own broker server over one WebSocket. This guide is for broker developers who want customers to trade from the chart.

FCS Trading Chart demo - EURUSD chart with order panel, positions table, Stop Loss and Take Profit
Click the preview to run the real trading chart right here, or open the full-screen demo in a new tab. Place a test order and watch the position, Stop Loss and Take Profit lines.
This demo runs on the free Access KEY, so the chart shows a small Powered by vunelix.com label in the bottom corner. With your own FCSAPI accessKey that label is removed automatically.

What It Is

The chart runs in the customer's browser. It shows prices, takes the customer's order and draws entry, Stop Loss and Take Profit lines. Your broker server holds the accounts and executes the trades. The two talk in plain JSON messages over a WebSocket.

Customer browser                              Your side
+--------------------------------------+
| fcsapi-chart.js          (chart)     |
| fcsapi-chart-broker.js   (broker API)|   JSON over WebSocket
| fcsapi-chart-trading.js  (trading UI)| <-------------------->  your broker server
+--------------------------------------+                         (sample-broker-server, modified)
                                                                          |
                                                                 your order engine / database

What the Broker Receives

ItemPurpose
fcsapi-chart.js
fcsapi-chart.css
The chart library. Load it on every page.
fcsapi-chart-broker.jsBroker API extension: trade lines, markers, candle data and chart events.
fcsapi-chart-trading.js
fcsapi-chart-trading.css
The built-in trading UI: order form plus positions, orders, history, balance, journal and account panels.
sample-broker-server/A complete Node.js reference server. You modify this to connect your own backend.
test-trading-platform.htmlWorking example: chart and built-in trading UI connected to the sample server.
test-broker.htmlWorking example: broker API methods only, for building your own trading UI.

Who Does What

  • The chart sends what the customer wants (place, modify, cancel, close) and displays whatever the server sends back. It never decides a fill.
  • Your server fills market orders at its own live price, fires Stop Loss and Take Profit even when the customer's browser is closed, fills pending limit and stop orders, liquidates on margin stop-out, and charges commission and spread.
  • You edit a small set of files in the sample server: config.js, settings/, handlers.js, storage.js, auth.js, admin.js and events.js. The core/ folder is the shared engine. You call it, you do not edit it.

Who This Guide Is For

  • A brokerage that wants a ready trading panel on the chart and a server it can adapt. Read Path A.
  • A brokerage that already has a web terminal and only needs the chart to show its trades. Read Path B.
  • Not sure which one fits? See Integration Paths.
Your Server Is the Source of Truth

The chart is a thin client. Balance, margin, fills, Stop Loss, Take Profit and position P&L are all decided by your broker server and pushed to the chart. A customer who edits the page in the browser cannot change a fill price or a balance.

What You Get:
  • Order form with Market, Limit and Stop orders, plus Stop Loss, Take Profit and Trailing Stop
  • Panels for positions, orders, history, balance, journal and account, and your own custom tabs
  • Three layouts: right, sidebar and bottom
  • Position actions you can switch on or off: partial close, close all, reverse and break-even
  • Server-side Stop Loss, Take Profit, pending order fills and margin stop-out, working with the browser closed
  • On a WebSocket connection, every open tab and device of one account stays in sync
  • Commission, spread, leverage and account currency are set on your server, not in the page
  • A demo mode (adapter: 'demo') that needs no server, for evaluating the UI

Run the Demo in 5 Steps

The shortest path to a working trading chart: start the sample broker server on your machine, open the example page, and place a test order. You need Node.js with npm and the sample broker server included with your broker package.

Step 1: Set Your Feed Keys

The server uses the fcsapi price feed, the same prices the chart shows. Otherwise a Stop Loss could fire at a price the customer never saw. It needs two keys, and they are not interchangeable. Set them in config.js, or as environment variables (the environment variable wins when both are set).

config.jsEnvironmentUsed for
wsKeyFCS_WS_KEYWebSocket streaming key (live ticks). Required: the server refuses to start without it. The free demo socket key fcs_socket_demo streams up to 5 symbols, which is enough to try the sample.
restKeyFCS_REST_KEYREST key for latest prices and currency conversion rates. Use your real API key here: a demo key does not return conversion rates.

Step 2: Install and Start the Server

cd sample-broker-server
npm install
npm start

npm start runs node server.js. The server listens on ws://localhost:6161 and prints the adapter settings to use in the chart. For another port run PORT=8080 node server.js.

Step 3: Open the Example Page

Open test-trading-platform.html from your package in the browser. In its script, ADAPTER_MODE is set to 'ws', so it connects to ws://localhost:6161 with accountId: 'usr12'. The sample auth.js accepts the token already set in that page.

The account is stored in users/user_usr12.json. For an account id with no file, the sample storage.js creates one with a balance of 10000 USD and leverage 100.

Step 4: Place a Test Order

  1. In the order form on the right, keep the Market tab selected.
  2. Enter a size, choose the Buy side and press the BUY button.
  3. Confirm the order in the dialog, if it appears (the example page sets confirmOrders: true).
  4. The position appears in the Positions tab and its entry line is drawn on the chart.
  5. Close the position from its row. The trade moves to History and the balance updates.

Step 5: Check Multi-Tab Sync

Open the same page in a second browser window. Place an order in one window and the other window updates without a reload. Every connection of one account receives the same pushes from the server.

No Server Yet?

Set ADAPTER_MODE to 'demo' in the example page (this passes adapter: 'demo' to mountTradingPlatform). The chart then runs paper trading inside the browser, stored in localStorage, with a starting balance of 10000 and leverage 100. Nothing reaches a server, and Stop Loss and Take Profit only run while the tab is open. Use it to evaluate the UI, not to test your integration.

If Something Does Not Work

What you seeCause and fix
Server exits with FCS_WS_KEY is emptyNo WebSocket key. Set wsKey in config.js or export FCS_WS_KEY.
Server exits with Port 6161 is already in useAnother copy of the server is running. Stop it, or start this one on another port and change the adapter url in the page to match.
Socket closes with code 4001 (invalid_token)The token in the page is not accepted by auth.js for that accountId. The chart does not retry after this close, so fix the token and reload.
Socket closes with code 4002 (invalid_account_id)Account ids must be lowercase and match /^[a-z0-9_-]{1,64}$/.
Order rejected with market_closedThe market for that symbol is closed and enforceMarketHours is on. Limit and stop orders are still accepted, or test on a symbol that is trading now.
Order rejected with market_closed_window, market_closed_dayoff or market_closed_suspendedYour own trading-hours rules in settings/trading-hours.json blocked the market order. Edit the rules, or set tradingHours.enabled to false in config.js. Limit and stop orders are not blocked.
Order rejected with market_status_unknownThe feed has not reported the session for a symbol you just opened. Retry after a moment or place a limit order.
Order rejected with fx_rate_unavailableThe server has no rate to convert the symbol's currency into the account currency. Set a real restKey, or pin the rate in fxOverrides in config.js.
Sample Defaults Are for Development Only

Out of the box the server stores each account as one JSON file under users/, and auth.js accepts one fixed development token for any account id. Both are there so the demo runs in a minute. Replace storage.js with your database and auth.js with your own session or JWT check before any customer connects.

After the Demo Runs:
  • config.js - every setting: keys, port, commission, spread, risk limits
  • settings/ - broker-settings.json (spread, commission and risk-limit overrides) and trading-hours.json are re-read on save with no restart; error-messages.json holds customer-facing rejection text, which your page loads and passes to mountTradingPlatform as errorMessages
  • handlers.js - your business logic. Every chart request already works from core/; override only the ones you change
  • storage.js - where account data lives (replace the JSON files with your database)
  • auth.js - how a token is checked against an account id
  • admin.js and events.js - back-office operations (deposit, withdraw, close all) and your alert hook
  • server.js - the process entry point. Add only your own process setup here (logging, crash reporting, health checks)
  • core/ - do not edit. A new release replaces core/ and dist/; server.js and your other files stay untouched

Two Integration Paths

There are two ways to put trading on the chart. Path A mounts the built-in trading platform and connects it to a broker server. Path B loads only the broker API, and you draw your own trades on the chart from your existing trading UI. Pick one before you write code.

Comparison

Path A - Built-in Trading PlatformPath B - Broker API Only
Example pagetest-trading-platform.htmltest-broker.html
Scriptschart + broker + tradingchart + broker only
Entry pointchart.mountTradingPlatform({ ... })chart.addDrawing(), chart.addMarker(), chart.on()
You buildNothing on the page. The order form and the positions, orders, history, balance, journal and account panels are included.Your own order form and panels. You call chart methods to draw trade lines and markers.
How it reaches your backendThe chart opens one WebSocket to your broker server and sends orders as JSON messages.The chart does not talk to your backend. Your own page does, and the chart only displays what you tell it.
Server neededYes: the sample broker server, modified for your backend (or the 'demo' adapter for evaluation).Your existing system.
Stop Loss, Take Profit, stop-outHandled by the price engine in the sample server's core/ folder.Handled by your existing system.
Best forA fast launch with a TradingView-style trading panel.Brokers with an existing web terminal who only need the chart.

Path A: What You Load

<link rel="stylesheet" href="dist/fcsapi-chart.css"/>
<link rel="stylesheet" href="dist/fcsapi-chart-trading.css"/>

<script src="dist/fcsapi-chart.js"></script>
<script src="dist/fcsapi-chart-broker.js"></script>
<script src="dist/fcsapi-chart-trading.js"></script>

The trading script adds chart.mountTradingPlatform(options) to the chart. Load the chart script and the broker script before it: chart.on('ready') and chart.on('symbolChange'), used to mount the panel and keep it on the chart symbol, come from the broker script. The adapter option decides where orders go:

AdapterConfigUse
Demoadapter: 'demo'No server. Paper trading kept in browser storage. For evaluating the UI.
WebSocketadapter: { type: 'ws', url, token, accountId }The real broker connection. Orders, positions, account, spread, commission and live pushes all travel over this one socket. It reconnects automatically after a network drop, but not after the server closes it with a 4000-4099 code (for example a rejected token).

Path B: What You Load

<link rel="stylesheet" href="dist/fcsapi-chart.css"/>

<script src="dist/fcsapi-chart.js"></script>
<script src="dist/fcsapi-chart-broker.js"></script>

The broker script adds the methods you drive from your own UI: addDrawing, updateDrawing, removeDrawing, getDrawings and clearDrawings for trade lines, getCandle, getCandles and getPip for price data, and chart.on() / chart.off() for the events ready, tick, symbolChange, periodChange and drawing. Buy and sell markers come from the chart script itself: addMarker, removeMarker and clearMarkers.

How to Choose

  • You have no web trading UI yet. Choose Path A. You get the full panel and a server that already handles fills, Stop Loss, Take Profit and margin.
  • You have a web terminal and an order engine you trust. Choose Path B. Nothing about your execution changes. The chart becomes a display for your trades.
  • You want the built-in panel but your own dialogs. Stay on Path A. The onEditPosition and onClosePosition hooks open your own modal instead of the built-in form, and the templates option points a panel at your own HTML file.
  • You want to see the UI before deciding. Run Path A with adapter: 'demo'. It needs no server.
Path A Includes Path B

A Path A page also loads the broker script, so every broker method and event from Path B is available there too. You can mount the built-in panel and still draw your own extra lines or markers with chart.addDrawing() and chart.addMarker().

Keep in Mind:
  • WebSocket is the only real-broker transport for Path A. There is no REST adapter.
  • On a WebSocket connection the chart does not calculate position P&L. Your server prices every open position and pushes the result.
  • The 'demo' adapter is browser-only. Its Stop Loss and Take Profit run only while the tab is open, so do not use it as a real account.
  • In Path B the chart never places, fills or closes anything. If a line on the chart and your order book disagree, your order book is right and the line must be updated by your code.

Best Way to Add Trading: Let an AI Model Do the Work

The fastest and safest way to connect this chart to your own system is to give an AI coding assistant the code, the documents and the example page, and let it do the work while you answer its questions.

Why use an AI model

A trading integration touches several files: the login check, storage, handlers, settings, alerts and the chart page. An AI coding assistant reads all of them in minutes and keeps them consistent. It does the repetitive work, such as connecting your database, replacing the test login, setting spread and commission, and wiring alerts. You only decide the business rules. The sample broker server is organised for this: the files you edit are few and clearly marked, and core/ stays untouched.

Use any coding assistant that can read your folder, edit files and run commands, for example Claude Code, Cursor or the agent mode of GitHub Copilot.

What to give the AI

  • The whole broker package folder. This is the server code with config.js, handlers.js, storage.js, auth.js, events.js, settings/ and my-templates/.
  • The guides in the document/ folder.README.md explains the server file by file and marks which files you may edit. HANDOFF.md lists what changed in each version and where to look when something breaks.
  • The broker guide.broker-documentation.html from the package has the same content as this page.
  • The example page.test-trading-platform.html shows how the chart mounts the trading platform and connects to the server. The live version is at broker.fcsapi.com.

Set it up in 5 steps

  1. Copy the package to a working folder on your computer and put it under git, so you can see and undo every change.
  2. Save the instruction file from the right panel as AGENTS.md in that folder. Some tools read another name, such as CLAUDE.md; use the one your tool reads. The same text is in the package as document/AI_INSTRUCTIONS.md.
  3. Start your AI tool in that folder and write: Read the instruction file and the documents it lists, then interview me before you change anything.
  4. Answer its questions in plain words.
  5. Look at what it changed (git diff), test with a demo account, and ask it to fix anything that is wrong.

Let the AI ask the questions

Do not start with "build me a broker". The instruction file makes the AI interview you first and wait for your "yes" before it edits anything. Typical questions:

  • Where do accounts live: the JSON files of the sample, or your database? Database passwords go in environment variables, never into the chat.
  • How do your customers log in, and how must the server check that login? The AI changes auth.js.
  • Account currency, demo balance and leverage.
  • Spread and commission rules. They go in config.js or settings/broker-settings.json.
  • Risk limits: stop-out level, maximum open positions, order sizes.
  • Trading hours and the exchanges or symbols customers may trade (settings/trading-hours.json).
  • Who is told when an order is rejected or a position is stopped out, by email, push or webhook (events.js).
  • Which panels customers see and how they look (my-templates/).
  • Where the server runs: machine, port, TLS and pm2.

Rules that keep it safe

  • The AI edits only the files marked editable in document/README.md and never anything inside core/. Your own work then survives a newer core/.
  • The server owns the money. No trading rule moves into the browser page.
  • Real keys and passwords stay in environment variables, not in files or in the chat.
  • The sample auth.js accepts a development token. The AI must replace it before you go public.
  • It works on a copy, never on your live server, and tests every change with a demo account.

You still review it

An AI can make mistakes, and this code handles money. Read the changes in handlers.js, storage.js and auth.js yourself. Place a test order with Stop Loss and Take Profit, close it, reload the page and check the balance. When it works, go through the Production Checklist.

Later: updates

Your changes live outside core/. When a newer package arrives, replace core/ and ask the AI to compare your files with the new document/README.md.

Short version

Give the AI the package folder, the document/ guides and the example page. Add the instruction file. Let it ask you questions, then let it work, and review the result.


Path A: Built-in Trading Platform

Path A adds a complete trading screen to the chart: order form, positions, pending orders, history, balance history, journal and account details. You load the chart files and call one method. You do not build any trading UI yourself.

Working example: test-trading-platform.html in the chart package. It connects to the sample broker server at ws://localhost:6161.

Step 1: Load the files

Load the files in this order. The broker script adds chart.on() events. The trading script adds chart.mountTradingPlatform().

<link rel="stylesheet" href="fcsapi-chart.css"/>
<link rel="stylesheet" href="fcsapi-chart-trading.css"/>

<div id="fcs_chartparent">
    <div id="fcs_chart"></div>
</div>

<script src="fcsapi-chart.js"></script>
<script src="fcsapi-chart-broker.js"></script>
<script src="fcsapi-chart-trading.js"></script>

Step 2: Mount the platform

Create the chart as usual. Wait for the ready event, check that your user is logged in, then mount. The panels are built around the chart inside fcs_chartparent, so that element must have a defined height.

chart.on('ready', () => {
    // Check YOUR user login here before mounting.
    chart.mountTradingPlatform({
        adapter: { type: 'ws', url: 'wss://trade.yourbroker.com', token: 'USER_SESSION_TOKEN', accountId: 'usr12' },
        layout:  'right'
    });

    // Keep the order form on the symbol the chart is showing
    chart.on('symbolChange', ({ symbol }) => chart.tradingSetSymbol(symbol));
});

mountTradingPlatform() returns the platform instance. Calling it again removes the previous one first. chart.unmountTradingPlatform() removes it.

Adapter: where orders and balances come from

ModeValueUse
demoadapter: 'demo'No server. Paper trading stored in the browser (localStorage), starting with a 10,000 USD account at 1:100 leverage. Use it to try the UI. This is also the default when adapter is omitted.
wsadapter: { type: 'ws', url, token, accountId }Real broker connection over one WebSocket. Your server fills orders, fires SL/TP and pushes account, position P&L, spread and commission. The chart sends what the user asked for and shows what the server sends back.

WebSocket is the only real-broker transport. There is no REST adapter.

ws optionTypeDefaultDescription
typestring-Must be 'ws'.
urlstringrequiredYour broker WebSocket endpoint. The sample broker server listens on port 6161. Use wss:// in production.
tokenstringnullThe user session token. It is sent in the first message and checked by validateToken() in auth.js on your server.
authobjectnullCustom auth payload, for example { apiKey, sig }. It is passed to validateToken() as the second argument.
accountIdstringnullThe account to trade on. Required when validateToken() returns true. Optional when it returns { accountId }, because the server then takes the account from the token. Allowed characters: lowercase a-z, 0-9, _ and -, up to 64.
reconnectbooleantrueReconnect after a drop. The wait starts at 1 second and doubles up to 30 seconds.
heartbeatnumber30000Ping interval in milliseconds. Keeps idle connections alive behind proxies and load balancers.
requestTimeoutnumber15000Milliseconds a request waits for the server before it fails.

Mount options

Every option is optional. With no options you get the demo adapter and the default layout.

OptionTypeDefaultDescription
adapterstring | object'demo'Data source, see the tables above.
layoutstring'right''right' = order form on the right, tabs along the bottom. 'sidebar' = everything in the right sidebar. 'bottom' = everything in the bottom panel.
rightWidthnumber280Starting width of the right panel in pixels.
bottomHeightnumber220Starting height of the bottom panel in pixels.
layoutLockbooleanfalsetrue hides the layout switch buttons and always uses your layout.
tabsarray | objectall six built-in tabsWhich tabs to show, in order. Built-in keys: positions, orders, history, balance, journal, account. Pass an object to rename a tab: { positions: 'My Positions' }. Any other key is a custom tab: the chart sends getData with that key as type and fills the columns your template declares with <th data-field>. The first tab is the active one.
positionActionsobjectall trueSet any to false to hide it: partialClose (close part of a position), closeAll (Close All / Profit / Loss), reverse (close and open the opposite side), breakEven (move SL to the open price). Reverse is always hidden on spot holdings.
orderActionsobjectall trueSet any to false to hide it: modify (edit price / SL / TP before the fill), cancelAll (Cancel All button).
confirmOrdersbooleanfalseShow a confirm dialog before an order is sent. Its "Don't show again" checkbox is remembered per account in the browser.
forceConfirmbooleanfalseWith confirmOrders: true: always confirm and hide the "Don't show again" checkbox. For compliance.
orderFormobject{}defaultQty: starting order size in the symbol's own units. It is raised to the symbol minimum when lower; when not set, the form starts at the minimum. statusTimeout: milliseconds before a success or error line clears, default 8000, 0 = never.
errorMessagesobject{}Your wording for rejections, as { code: 'text' }, for example { insufficient_margin: 'Not enough free margin in your account.' }. One map serves the order form, positions, orders and journal. The sample server's index.html loads it from the messages object in settings/error-messages.json.
templatesobject{}URLs of your own HTML files for a panel. Keys: positions, orders, history, account, balanceHistory, or a custom tab key. Samples are in the my-templates folder of the sample broker server. The order form has no template; style it with CSS.
onEditPositionfunctionnullCalled with the position object when the user clicks edit, instead of the built-in form. Open your own modal, then save with chart.tradingModifyPosition(id, { stopLoss, takeProfit }).
onClosePositionfunctionnullCalled with the position object when the user clicks close, instead of the built-in close. Close with chart.tradingClosePosition(id).
commissionobjectnullFallback rule for the commission estimate in the order form: { type, value, min, max }, type = per_trade, per_lot, per_unit or percent. Commission sent by your server wins.
fxOverridesobject{}Fixed exchange rates as { CCY: usdPerUnit } for a currency with no live rate. Fallback only: when your server sends its own list (the sample server does at login), that list replaces this one.
qtySizingobjectbuilt-in per asset typeOrder size limits as { byType: { stock: { minQty, maxQty, qtyStep } }, bySymbol: { 'NASDAQ:AAPL': { ... } } }. Sizing sent by your server for a symbol wins.

Layout rules

  • The user can switch layout, resize and collapse the panels. This is saved in the browser and wins over your layout, rightWidth and bottomHeight on the next visit. With layoutLock: true your layout always wins; saved sizes still apply.
  • In a multi-chart layout the platform moves to the bottom panel and locks there. It moves back when the user returns to a single chart.
  • In replay mode all trading runs in a temporary sandbox. No order reaches your server and the real account is not touched.

Connection handling

  • After a drop the chart reconnects, signs in again and reloads the panels from a fresh snapshot. You do not handle this.
  • If the server closes the socket with a code from 4000 to 4099 (for example 4001 invalid_token), the chart stops reconnecting. Mount again with a fresh token.
  • Orders, modifications, reverses, cancels and closes are never queued while offline. They fail at once with not_connected, so a click during an outage is not filled later at another price.
The server is the authority

Mount options only change what the user sees. Limits, margin, market hours, spread, commission and every fill are decided by your broker server. A user can edit anything in the browser, so never use a mount option to enforce a rule.

Before you go live:
  • Mount only after your own login check. The token you pass must belong to the logged-in user.
  • Tie the token to the account in validateToken(). A token that is valid for any accountId lets one customer open another customer's account.
  • Use a wss:// URL so the token and the orders travel encrypted.
  • Wire symbolChange to chart.tradingSetSymbol(), or the order form stays on the first symbol.
  • To rebrand the panels, load your own stylesheet after fcsapi-chart-trading.css.

Path B: Broker API Only, Build Your Own GUI

Use this path when you already have your own order form and position tables and only need the chart. Load the chart and the broker script, without the trading script. Your system keeps the orders. The chart shows what you send it (entry, Stop Loss and Take Profit lines, trade markers) and reports what the user does on the chart through events.

Load the Scripts

Load the chart first, then the broker extension. The broker script adds the drawing, data and event methods below to every FCSAPIChart instance. Use the path where you host the chart files.

<link rel="stylesheet" href="fcsapi-chart.css">
<script src="fcsapi-chart.js"></script>
<script src="fcsapi-chart-broker.js"></script>

Events

Subscribe with chart.on(event, callback). Remove one listener with chart.off(event, callback), or all listeners of an event with chart.off(event).

EventFires whenCallback data
readyOnce, when the chart has loaded its first candles{ symbol, period, candles }. candles is the number of bars loaded
tickOn every live price update from the price socket{ symbol, mode, price, open, high, low, close, volume, timestamp, ask, bid, isNewCandle, chartIndex }. price is the last price; the OHLC fields and timestamp belong to the latest bar. isNewCandle is true when a new bar has opened
symbolChangeThe user or your code changes the symbol{ symbol, period, chartIndex }
periodChangeThe user or your code changes the timeframe{ symbol, period, chartIndex }
drawingThe user draws, drags, deletes or clicks a drawing{ type, id, source, drawingType, drawing }. type is created, moved, deleted or clicked. source is broker for your drawings and gui for the user's own. drawing holds type, points, price, price2, timestamp, timestamp2, color, lineWidth, style, text, locked

Methods

MethodReturnsWhat it does
addDrawing(options)Promise: the drawing id, or null on failureAdds a drawing. If one of your drawings already has the same id, that drawing is updated instead
updateDrawing(id, options)true / falseChanges one of your drawings: the price of point 1 and the style, text, tooltip and lock options. It does not move point 2 or the time position
removeDrawing(id)true / falseRemoves one of your drawings
getDrawings(type, source)ArrayLists drawings. type filters by drawing type (or null). source is broker (default, only yours), gui (drawn by the user) or all
getAllDrawings(type)ArrayShort form of getDrawings(type, 'all')
clearDrawings(type, source)Number removedRemoves drawings, same arguments as getDrawings. With gui or all, locked drawings are kept
addMarker(marker)-Puts a marker (buy, sell, arrow and more) on a candle
removeMarker(id)-Removes the marker with that id
clearMarkers()-Removes every marker that is not locked, including markers the user placed
getCandle(index)Candle or nullOne bar: { open, high, low, close, volume, timestamp }. Index 0 is the latest bar, 1 is one bar back
getCandles(count, offset)ArraySeveral bars, newest first. offset skips that many bars from the latest
getPip()NumberSmallest price step of the current symbol, for example 0.00001 on a 5-decimal EURUSD
getSymbol(), getPeriod()StringCurrent symbol and timeframe
getVisibleRange()Object or null{ from, to, firstIndex, lastIndex, count } for the bars on screen. from and to are unix timestamps in seconds
scrollToTime(timestamp)true / falseScrolls the chart so that time is in the middle

The standard chart methods also work, for example setSymbol(), setPeriod() and exportImage(). See the chart documentation.

Drawing Options

Options of addDrawing() and updateDrawing(). Only type is required.

OptionDescription
typehorizontal, vertical, trendline, ray, arrow, circle, fibonacci, note, pricelabel, tradeline
idYour own id, used to update or remove the drawing. Generated when you leave it out
price, price2Price of point 1 and point 2. A horizontal line needs only price
index, index2Candle position counted from the right: 0 is the latest bar
timestamp, timestamp2Unix time of point 1 and point 2. Used instead of index when both are given
color, lineWidth, styleLine color (hex), thickness in pixels, and solid or dashed
text, textColor, textSize, textPositionLabel on the line. Position is above-left, above-center, above-right, below-left, below-center or below-right
tooltipTitle, hoverText, tooltipModeTooltip shown when the user hovers the drawing. Use \n for a new line in hoverText. tooltipMode: 'off' hides it
locktrue: the user cannot drag or delete the drawing
lockDeletetrue: the user can drag the drawing but cannot delete it
settingsLocktrue: no settings panel and no right-click menu for the drawing
chartIndexTarget chart in a multi-chart layout (0, 1, ...). Without it the active chart is used

Extra options per type: fibonacci accepts levels, levelColors and visibleLevels. note and pricelabel accept backgroundColor, borderColor, borderRadius and padding. A note needs text. A pricelabel shows the price when text is empty, and accepts showPointer.

Markers

A marker is a small icon pinned to one candle, for example a buy sign where the order was filled. Set price to the candle low to place it below the bar, or high to place it above.

const c0 = chart.getCandle(0);
chart.addMarker({
    id: 'trade-marker',            // needed to remove it later
    timestamp: c0.timestamp, price: c0.low,
    type: 'buy', color: '#26a69a',
    lock: true                      // user cannot move or delete it
});
chart.removeMarker('trade-marker');

Marker types:

  • Trading:buyselllongshortentryexittpsladdreducelockstop
  • Arrows:arrow-uparrow-downarrow-up-strongarrow-down-strongtrend-uptrend-down
  • Price levels:highlowsupportresistancepivotbreakoutkey-leveltarget
  • Patterns:swing-highswing-lowdouble-topdouble-bottomreversalcontinuation
  • Shapes:circlesquaretrianglediamondflagstar
  • Signals:bullishbearishcheckcrosswarningquestioninfonotefirelightningeyeclockdollar
  • Numbers:number-1 to number-9

Let the User Drag a Stop Loss Line

Leave lock off so the line can be dragged, and set lockDelete so it cannot be deleted. When the user drops the line, the drawing event fires with type: 'moved' and the new price in e.drawing.price. Send that price to your own server. The example on the right shows the full flow.

Working Example

The broker package includes test-broker.html, a page with buttons that call the methods on this page. Press a button and read the matching function in the page source: open the broker API example.

Good to Know:
  • Attach your chart.on() listeners right after you create the chart, before the data loads. Call getCandle(), getPip() and addDrawing() after ready has fired.
  • Drawings are saved per symbol in the user's browser and come back after a page refresh with the same id. Use fixed ids such as trade-sl, so calling addDrawing() again updates the line instead of adding a second one. Register chart.on('drawing', ...) before the data loads, so the chart links the saved drawings to your ids.
  • lockDelete is not saved with the drawing. After a page refresh, call addDrawing() again with the same id and options to set it again.
  • Markers are saved per symbol in the browser too. addMarker() does not replace a marker with the same id, so call removeMarker(id) first when you draw it again.
  • The drawing event reports only what the user does. Your own addDrawing(), updateDrawing(), removeDrawing() and clearDrawings() calls do not fire it.
  • The tick event needs live prices, so keep the chart option enableSocket on (the default).
  • In a multi-chart layout the data and drawing methods take an optional chartIndex, for example chart.getSymbol(1) for the second chart. Without it they use the active chart.

Running the Sample Broker Server

The sample broker server is a Node.js WebSocket server that the trading chart connects to. It keeps the accounts, positions and orders, fills orders at the live FCS price, and fires stop-loss, take-profit and margin stop-out on the server, even when the customer has closed the chart. Run it as it is first, then change a small set of files to connect your own database, login and business rules.

Requirements

  • Node.js and npm on the machine that runs the server.
  • The sample-broker-server/ folder. It sits inside the chart folder. Its only npm dependency is the ws WebSocket package.
  • Two FCS API keys, set in config.js or as environment variables. wsKey is the WebSocket streaming key (live ticks). restKey is the REST key: it polls the latest price about once a minute for symbols whose pending orders are far from the market, and it is the fallback when a live tick is slow. The two keys are not interchangeable.
  • The chart build (dist/) on the page where your customers trade.

The server must use the same FCS price feed as the chart. Otherwise SL/TP would fire at prices the customer never saw.

Install and Start

cd sample-broker-server
npm install

# set your two FCS keys in config.js (wsKey + restKey),
# or pass them as environment variables (see below)

node server.js          # listens on ws://localhost:6161

npm start runs the same command. To use another port: PORT=8080 node server.js.

At start the server prints the core version, the active socket transport, the line FCS sample broker WS server running on ws://localhost:6161, and the adapter settings (url, token, accountId) that the sample login accepts.

Now open test-trading-platform.html from the chart folder (online copy). It connects to ws://localhost:6161 with accountId: 'usr12'. Place a trade, then open the page in a second browser window. Both windows stay in sync, because the server pushes every change to all open connections of that account.

The Files and What Each One Is For

File / folderYou edit it?What it is for
server.jsRarelyThe entry point. It only calls require('./core/boot').start(). Add process-level setup here if you need it (environment loader, crash reporting, your logger).
config.jsYesEvery setting in one place: ports, feed keys, connection limits, commission, spread (askBid), swap, account currency, risk limits, order sizing, audit log. Takes effect on restart.
handlers.jsYesYour business logic: your own checks before an order, a dynamic spread, your commission tiers, your order IDs (_uid), and the data for custom tabs (getData).
storage.jsYesWhere account data lives. The sample writes one JSON file per user, users/user_<id>.json. Replace it with queries to your database.
auth.jsYesOne function, validateToken(). It decides if a connection may use the account it asked for. Replace the sample check with your sessions or JWT.
admin.jsYesBack-office operations (deposit and withdraw, close one or all positions, block an account) that your own backend calls as POST /admin/<op> on a separate, server-to-server port.
events.jsYesOne onEvent(accountId, type, data) hook, fired on every account event. Use it for alerts, email and webhooks.
settings/Yesbroker-settings.json (live overrides for spread, commission and risk caps) and trading-hours.json (your trading window per exchange) are re-read by the running server, at most every recheckMs (5 seconds by default), with no restart. error-messages.json (the rejection messages customers see) is not read by the server: your trading page loads it and passes it to mountTradingPlatform({ errorMessages }), so an edit shows after a page reload.
broker-report.jsRun itnode broker-report.js prints your commission and spread income across all clients in USD. node broker-report.js PKR prints it in another currency. Read-only. It reads the JSON files in users/, so it works with the default storage.js. Accounts marked isDemo: true are left out.
transport.example.jsOptionalAn example that runs the server on uWebSockets.js instead of ws. Install uWebSockets.js, then rename the file to transport.js to activate it. It is an untested example: load-test it before production.
pm2_server.config.jsOptionalThe PM2 process file for production (see below).
core/NoThe engine: connection and auth handshake, order and position mechanics, margin and P&L math, the price engine (SL/TP, stop-out, pending fills), the price feed, currency conversion, swap, the audit log writer. You call it, you do not change it.
users/DataThe per-user JSON files written by the default storage.js.
logs/DataThe trade audit log, one JSON line per event: logs/user_<id>.log per client, or one logs/trades.log. Created automatically. Controlled by tradeLog in config.js.

What You Edit: handlers.js and config.js

config.js holds values. Change a number, save, restart. A restart drops every customer socket, so once you are live, put the values you change during market hours in settings/broker-settings.json instead. Only these keys can be changed there: askBid, commission, commissionOnClose, tradingMode, qtySizing, stopOutLevel, maxOpenPositions, maxPendingOrders, enforceMarketHours, checkTradeable, brokerCurrency. Everything else (ports, keys, limits, currency settings, audit log) needs a restart, and the server refuses to boot if you put another key in the JSON file.

A key in broker-settings.json replaces the config.js value as a whole. It is not merged. For example, an askBid with only spreadPips drops the perSymbol block from config.js. The server logs the dropped sub-keys.

handlers.js holds logic. Every chart request is already implemented in core/handlers-base.js. Your file spreads that base and overrides only what is really yours:

const { base } = require('./core/handlers-base');

module.exports = {
  ...base,

  // Your rule first, then let the base do the mechanics
  async placeOrder(ctx) {
    if (await myRisk.isBlocked(ctx.accountId)) return ctx.fail('symbol_not_allowed');
    return base.placeOrder(ctx);
  },
};

Anything you do not list keeps the base implementation, so a request type added in a later release works with no edit. Do not copy a base function into handlers.js without changing it: the copy replaces the base for good, and later fixes to that function will not reach you.

What Must Stay Untouched: core/

Never edit a file inside core/. A value you configure belongs in config.js or settings/. The code that reads that value belongs in core/. Because none of your work lives in core/, an upgrade is: replace core/ and the chart dist/ folder, then restart. A release does not touch server.js, config.js, handlers.js, storage.js, auth.js, admin.js, events.js or settings/.

If your own files live somewhere else (a database-backed storage module, for example), pass them in server.js instead of editing core/:

const boot = require('./core/boot');
boot.start({ storage: require('./my-db-storage') });

config, handlers, storage and auth are required. admin, events and transport are optional: leave one out and that feature stays off (for transport, the default ws package is used).

Environment Variables

Ports and secrets can be set from the environment, so you do not commit them. A value from the environment wins over the value in config.js.

Variableconfig.js keyDescription
PORTportWebSocket port the chart connects to. Default 6161.
FCS_WS_KEYwsKeyFCS WebSocket streaming key. The server will not start with an empty wsKey.
FCS_REST_KEYrestKeyFCS REST key for the latest price.
ADMIN_PORTadminPortPort of the server-to-server admin endpoint. Default 6162.
ADMIN_HOSTadminHostAddress the admin endpoint binds to. Default 127.0.0.1 (this machine only).
ADMIN_TOKENadminTokenSecret for the admin endpoint. Always set your own strong value in production.

Production: Run It Under PM2

If the process stops, SL/TP and stop-out stop for every customer. On a fatal error the server logs it and exits, and it expects a process supervisor to start it again. The included PM2 process file, pm2_server.config.js, does this:

pm2 start pm2_server.config.js
pm2 logs broker_server
pm2 restart broker_server
pm2 save            # survive reboots (after: pm2 startup)

The process is named broker_server. It restarts automatically, restarts if memory passes 500 MB, and writes to logs/pm2-out.log and logs/pm2-error.log. Set PORT, ADMIN_PORT, ADMIN_HOST, ADMIN_TOKEN, FCS_WS_KEY and FCS_REST_KEY in the env block of that file.

The file runs one instance in fork mode on purpose. Positions, the trigger book (SL/TP and pending order levels) and the price feed live in the memory of that one process. Do not switch to cluster mode and do not run two copies behind a load balancer.

The server refuses to start half-configured

At boot the server checks its own setup and exits with a clear message instead of running in a broken state:
• No WebSocket key - a live feed is required, because SL/TP and stop-out fire on live ticks. REST-only is not supported.
• Invalid settings/broker-settings.json - bad JSON, or a key that can only be changed with a restart.
• Trading hours enabled but the rules file is missing or invalid - fix settings/trading-hours.json or set tradingHours.enabled to false.
• Port already in use - another copy is running. Stop it or start on a different PORT.
• A required file is missing - config.js, handlers.js, storage.js or auth.js.

Before You Go Live:
  • Feed keys: set your own wsKey and restKey. A demo socket key streams at most 5 symbols.
  • auth.js: replace the sample token check. Only accept a token after you confirmed it belongs to the requested accountId.
  • storage.js: replace the JSON files in users/ with your database. The per-user state shape must stay the same.
  • Admin port: never expose it to customers or the public internet, and set your own ADMIN_TOKEN.
  • TLS: run the server behind a reverse proxy (nginx, Caddy) so the chart connects with wss://, not ws://.
  • limits.behindProxy: set true when a proxy terminates SSL in front of the server, false when the chart connects to the port directly. It decides how the per-IP connection cap reads the customer IP.
  • feedDebug: set false in config.js once you trust the feed, to keep the console quiet.
  • admin-control/: the demo admin console is a development tool. Remove it from a production server.
  • One process only: keep a single instance under PM2. Cluster mode or a second copy would split the in-memory state.

User Data: Request Flow and Required Formats

The chart is a thin client. It sends a request over the WebSocket. Your broker server decides, saves and replies, then pushes the change to every open tab of that user. This section shows that round trip and the exact shape of the account, position, order and history records the chart panels expect.

How a request flows

  1. The chart sends a request, for example placeOrder, with a unique reqId.
  2. core/protocol.js checks that the socket is authenticated and that the token and accountId match the ones used at login. It then calls the handler with the same name as type. An unknown type is rejected with unknown_type.
  3. The account record is loaded once with storage.ensureAccount(accountId) and passed to the handler as ctx.state.
  4. Your handlers.js runs your own checks. The base handler in core/handlers-base.js then does the mechanics: re-price at the live quote, check margin, build the position.
  5. The changed record is written back with storage.save(accountId, state).
  6. ctx.ok(payload) answers the request. ctx.broadcast(type, data) pushes the change to every tab and device of that account.
  7. The chart updates its panels. For a filled position it draws the entry, stop-loss and take-profit lines.

Requests of the same account run one at a time, in order, so two orders can never spend the same margin. Different accounts run in parallel. You can watch the whole flow in the live example page.

On connect the chart sends auth first. The server answers authResult, then one snapshot with account, positions, orders, the newest 100 history rows and the FX pins. The panels fill without extra requests. Older history is fetched with getHistory and the filters symbol, limit, fromTime and toTime. Frame formats are in Protocol Reference.

Per-user state shape

This is the one record storage.js reads and writes per account. The sample keeps it in users/user_<id>.json; you keep it in your database. The storage can change, the shape must stay the same.

{
  "account":        { ... },   // one object      -> account panel and balance widget
  "positions":      [ ... ],   // open positions  -> Positions tab and lines on the chart
  "orders":         [ ... ],   // pending orders  -> Orders tab
  "history":        [ ... ],   // closed trades   -> History tab
  "balanceHistory": [ ... ],   // optional ledger -> Balance History tab
  "custom":         { ... }    // optional arrays for your own tabs, keyed by tab name
}

account (object)

FieldTypeMeaning
idstringAccount id. Must be the same accountId the chart logs in with.
namestringDisplay name.
isDemobooleanOptional. true marks practice money and the account panel shows DEMO. Missing or false shows LIVE.
balancenumberClosed balance.
equitynumberBalance plus floating P&L and accrued swap of open positions.
currencystringAccount currency code, for example USD or PKR. All money fields are in this currency.
displaySymbolstringOptional. Your own currency label shown before amounts, for example RS.
marginnumberMargin in use by open positions and pending orders.
freeMarginnumberEquity minus margin.
leveragenumberFlat leverage, 100 means 1:100. Zero, negative or non-numeric is treated as 1.
leverageByTypeobjectOptional, per asset class, for example { "stock": 1, "forex": 100, "crypto": 20 }.
leverageBySymbolobjectOptional, per symbol, for example { "PSX:HBL": 100 }. Wins over both fields above.
askBidobjectOptional per-user spread. Same shape as config.askBid and layered over it for this user only.
commissionobjectOptional per-user commission, { type, value, min, max }. Wins over the per-symbol and default commission.
fxOverridesobjectOptional pinned FX rates for this user, USD per 1 unit of the currency. Layered over config.fxOverrides.
swapFreebooleanOptional. true skips overnight swap for this account.
realizedPnlnumberLifetime closed P&L.
maxDrawdownnumberPeak-to-trough loss. Your engine maintains it; the chart only displays it.

positions[] (open positions)

The server stamps the economics on each position when it opens. The price engine keeps currentPrice, pnl and pnlPercent live and pushes them as positionsPriced every config.positionPushMs (default 2000 ms). The chart shows these values as sent and does not recompute them.

FieldTypeMeaning
idstringUnique position id. Ids must be unique across positions, orders and history.
symbolstringFor example EURUSD or BINANCE:BTCUSDT.
sidestringbuy or sell.
qtynumberLots or units.
avgPricenumberFill price.
currentPricenumberLive price on the exit side of this user's quote, spread included: a buy is valued at the bid.
stopLoss, takeProfitnumber or nullLevels. The chart draws the lines.
trailingobject or nullOptional trailing stop, { mode, value }. The server engine moves stopLoss for it.
pnlnumberFloating P&L in account currency.
pnlPercentnumberReturn on margin, pnl / margin * 100. Not the price change percent.
commissionnumberOpen-side fee charged.
marginnumberCapital this position locks.
notionalnumberPosition size in money: qty x contract size x price.
swapnumberOvernight financing accrued. Negative is charged, positive is credited, 0 when no swap rate is set.
convCurrency, convRatestring, numberQuote currency of the symbol and the rate used to convert it to the account currency (1 when they are the same).
leveragenumberLeverage this position was margined at (1 for spot).
spotbooleantrue means owned outright: no margin stop-out, and the Reverse action is hidden for that row.
openTimenumberOpen time, milliseconds since epoch.

The sample server also stores rawPrice, spreadRevenue and _profile (the symbol profile used at open) on each position. Keep them in your database: _profile lets margin and P&L be computed again after a restart.

orders[] (pending limit and stop orders)

FieldTypeMeaning
id, symbol, side, qtySame as on a position.
typestringlimit or stop.
pricenumberTrigger price.
stopLoss, takeProfitnumber or nullLevels applied to the position when the order fills.
statusstringworking while the order is pending.
createdAtnumberPlacement time, milliseconds since epoch.
expiresAtnumber or nullGood-till-date time in milliseconds. The server cancels the order once this time passes (checked every 15 seconds by default). null means good till cancelled.
ocobooleanWhen this order fills, the account's other OCO orders on the same symbol are cancelled. With config.ocoBySymbol on, any fill cancels all other orders on that symbol.
margin, notional, convCurrency, convRate, leverageA snapshot taken at placement, for display. The real margin is computed again at the fill, with the FX rate of that moment.

The order the chart sends

placeOrder arrives as ctx.data.order. A market order replies with the new position; a limit or stop order replies with the pending order.

FieldTypeMeaning
symbol, side, qtyRequired. A missing symbol or side is rejected with invalid_order, a bad quantity with invalid_qty.
typestringmarket, limit or stop.
pricenumberTrigger price for limit and stop. For a market order it is only the quote the user saw: the server fills at its own live price.
stopLoss, takeProfitnumber or nullAbsolute levels.
slRaw, tpRaw, clientPriceobject, numberWhat the user typed, { mode, value } with mode price, pips, pct or amount, and the quote it was based on. When a market order fills at a different price, the server rebuilds pips and pct levels from its own fill price.
expiresAt, oconumber, booleanPending orders only, see the table above.
profileobject or nullSymbol profile from the data feed. Used only when the server has not cached its own yet.

history[] (closed trades)

FieldTypeMeaning
id, positionIdstringHistory row id and the id of the position it closed.
symbol, side, qtyWhat was closed. On a partial close qty is the part that was closed.
openPrice, closePricenumberEntry and exit price.
pnlnumberRealized P&L before fees. Your spread is already inside it, because it is part of the fill prices.
commissionnumberClose-side fee only.
openCommissionnumberOpen-side fee that belongs to this close.
commissionTotalnumberopenCommission + commission, the full round-trip fee.
spreadTotalnumberYour spread revenue on the round trip, as a money amount. For information only; it is never subtracted again.
swapnumberOvernight financing realized on this close.
netPnlnumberpnl + swap - commissionTotal, the real effect of the trade on the balance.
reasonstringWhy it closed: manual, sl, tp, margin (stop-out), reverse, partial or admin_close (closed through the Admin API).
openTime, closeTimenumberMilliseconds since epoch.

New rows go to the start of the array, so history is stored newest first.

balanceHistory[] and custom{}

balanceHistory is your real ledger, returned newest first by getBalanceHistory. Each entry has id, type, a signed amount (plus is a credit, minus is a debit), balance (the running balance after the entry), time in milliseconds and a note. The sample server writes deposit and withdraw (Admin API), nbp (negative-balance protection) and reset (demo reset). It does not add a row per trade; use trade and fee for your own entries. The panel sums deposit and withdraw rows into its totals.

custom holds arrays for your own tabs, keyed by tab name. The chart asks for one with getData and data.type set to the tab key, and fills the columns of your template.

Account ids

The account id is the key for the open sockets, the request lock and the price engine that watches SL/TP. It is checked at login and never rewritten.

storage.isValidAccountId('usr12')        // true
storage.isValidAccountId('usr12_demo')   // true
storage.isValidAccountId('alice@b.com')  // false: @ and . are not allowed
storage.isValidAccountId('Usr12')        // false: uppercase is not allowed

// the rule:  /^[a-z0-9_-]{1,64}$/

An id outside this rule gets { "v": 1, "type": "authResult", "ok": false, "error": "invalid_account_id" } and the socket is closed with code 4002. If your user ids are emails or contain uppercase letters, issue a separate trading account id or convert them before they reach the chart.

Changes made outside a handler

The price engine broadcasts SL/TP hits, pending-order fills, expiries and margin stop-outs for you. For a deposit or withdrawal, call the Admin API POST /admin/balance with { "id", "amount", "note", "token" }. It changes the balance under the account lock, adds the ledger row and pushes account to the open charts. If your own code changes an account, save it, then broadcast the new state:

const protocol = require('./core/protocol');
protocol.broadcast(accountId, 'account', updatedAccount);
Moving from JSON files to your database

Replace ensureAccount() and save() in storage.js with your queries. Every storage function may be async; the core awaits all of them. Keep isValidAccountId(): without it the server logs a warning at boot and does not check ids.

Before you go live, also export listRiskRows(). It returns [{ id, state: { account, positions, orders } }] for all accounts in one query. Without it the price engine reads every account in full, one by one, every 60 seconds and again every 15 seconds.

Both listRiskRows() and listAccountIds() must return demo accounts and offline accounts too. An account missing from them has no working stop-loss, take-profit or stop-out while its owner has no tab open.

Rules to keep:
  • Use ctx.state inside a handler. Do not call storage.ensureAccount() again: you get a second object and its changes are lost.
  • Broadcast on every state change. That keeps a laptop and a phone of the same user in sync without polling.
  • Never trust a price from the browser. Market orders, closes and reverses are filled at the server quote.
  • All times are milliseconds since epoch. All money values are in the account currency.
  • The server sends both names for these fields and the chart reads either: symbol / instrument, pnl / unrealizedPl, margin / marginUsed, freeMargin / marginAvailable, price / limitPrice / stopPrice.
  • The sample reads the user file fresh on every request, so a hand edit takes effect on the next request. Useful in development.

WebSocket Protocol Quick Reference

The chart and your broker server talk over one WebSocket with plain JSON messages. This page lists every message the chart sends and every message the server pushes, so you can write the server in any language or check your own server against the sample.

Connection Lifecycle

  1. The chart opens the socket and sends auth first. A request sent before auth is refused with not_authenticated.
  2. The server checks the token with validateToken() in auth.js and replies authResult.
  3. The server sends one snapshot: the account, open positions, pending orders, the latest 100 history rows and the FX pins.
  4. The chart sends requests. Each request has a reqId and gets exactly one ack with the same reqId.
  5. The server pushes events at any time. Pushes have no reqId. Account events go to every tab and device of that account.
  6. The chart sends ping every 30 seconds (adapter option heartbeat). The server answers pong.
  7. If the socket drops, the chart reconnects (1 second delay, doubling up to 30 seconds), sends auth again, gets a fresh snapshot and sends watchSymbol again for the symbol it shows.

Message Envelope

Every request has the same outer shape. The payload is inside data. v is the protocol version, currently 1.

// Request (chart to server)
{ "v": 1, "type": "cancelOrder", "reqId": "b3f1c2e0-7d4a-4c1e-9a55-2f6e0c9d1a10",
  "token": "YOUR_SESSION_TOKEN", "accountId": "usr12",
  "data": { "accountId": "usr12", "orderId": "360393_a7" } }

// Reply on success
{ "v": 1, "type": "ack", "reqId": "b3f1c2e0-7d4a-4c1e-9a55-2f6e0c9d1a10",
  "ok": true, "s": "ok", "data": true, "d": true }

// Reply on failure
{ "v": 1, "type": "ack", "reqId": "b3f1c2e0-7d4a-4c1e-9a55-2f6e0c9d1a10",
  "ok": false, "s": "error", "error": "order_not_found", "errmsg": "order_not_found" }
  • error is the machine code and errmsg is the human detail. A handler writes one string, ctx.fail('code: detail'), and the server splits it at the first colon. With no detail, errmsg repeats the code.
  • errparams is optional. It carries the values behind a message, for example { "exchange": "PSX", "from": "09:32", "to": "15:30", "timezone": "Asia/Karachi" } on market_closed_window, so your own wording can use them as placeholders.
  • The chart waits 15 seconds for an ack (adapter option requestTimeout), then rejects the request as timed out.
  • The chart repeats accountId inside data. The sample server ignores it and always uses the account the socket authenticated as.

Handshake and Control Messages

typeDirectionShape
authchart to server{ v, type, token, auth, accountId }. auth is an optional custom payload, passed to your validateToken(token, auth, accountId).
authResultserver to chart{ v, type, ok: true } or { type, ok: false, error }. After a failed auth the server closes the socket.
snapshotserver to chart{ v, type, data: { account, positions, orders, history, fxOverrides } }. Sent once after every successful auth.
ping / pongboth{ type: "ping", t } is answered with { type: "pong", t } (same timestamp).
ackserver to chartThe reply to one request, see the envelope above.
errorserver to chart{ type, error: "invalid_json" } for a frame that is not JSON, { type, reqId, error: "not_authenticated" } for a request before auth.

Requests (Chart to Server)

The data column is the content of data in the request. The ack.data column is what a successful reply carries. ? marks an optional field.

typedataack.dataPushes that follow
getAccountsnoneArray of account objectsnone
getAccountnoneOne account objectnone
getPositionsnoneArray of open positionsnone
getOrdersnoneArray of pending ordersnone
getHistory{ filters: { symbol?, limit?, fromTime?, toTime? } }Array of closed trades, newest first. Times are ms epoch. fromTime is inclusive, toTime is exclusive.none
getBalanceHistory{ filters: { fromTime?, toTime?, limit? } }Ledger rows { id, type, amount, balance, time, note }, newest firstnone
getData{ type, filters? }Array of rows for a custom tab. type is the tab key.none
watchSymbol{ symbol }{ symbol, ...spread rule, commission, sizing, tradingMode, ocoBySymbol, checkTradeable }Later spread, commission and sizing pushes for that symbol
placeOrder{ order: { ... } }, see belowMarket: the new position with status: "filled". Limit or stop: the new order with status: "working".positionOpened + account, or orderPlaced + account
modifyPosition{ positionId, updates: { stopLoss?, takeProfit?, trailing? } }truepositionModified
closePosition{ positionId, closePrice, closeQty? }trueFull close: positionClosed, historyAdded, account. Partial close (closeQty below the position qty): positionModified, historyAdded, account.
reversePosition{ positionId, newQty?, closePrice }truepositionClosed, historyAdded, positionOpened, account
modifyOrder{ orderId, updates: { price?, stopLoss?, takeProfit? } }trueorderPlaced with the full updated order
cancelOrder{ orderId }trueorderCancelled, account
requestRefresh{ tabs? }Sent without a reqId. The chart does not wait for a reply.refresh

While connected, the chart answers getAccount, getPositions and getOrders from the snapshot it keeps in memory, so these rarely reach the wire. Your server must still implement them.

closePrice is the price the chart was showing. The sample server does not trust it. It closes at its own live quote with your spread applied, and uses the client value only when it has no quote for that symbol.

The placeOrder Order Object

{ "order": {
    "symbol": "EURUSD",            // the chart also sends "instrument"
    "side": "buy",                 // "buy" | "sell"
    "type": "market",              // "market" | "limit" | "stop"
    "qty": 0.1,
    "price": 1.0852,               // market: the quote the user saw. limit/stop: the trigger price
    "stopLoss": 1.0800,            // or null
    "takeProfit": 1.0900,          // or null
    "trailing": { "mode": "pips", "value": 20 },   // or null
    "expiresAt": null,             // pending orders only: ms epoch when the order expires (GTD), null = no expiry
    "oco": false,                  // pending orders only: when this fills, cancel the other OCO orders on the same symbol
    "slRaw": { "mode": "pips", "value": 50 },      // what the user typed for SL, or null
    "tpRaw": null,                 // what the user typed for TP, or null
    "clientPrice": 1.0852,         // the quote the chart used to compute stopLoss / takeProfit
    "profile": { }                 // the symbol profile from the data feed
} }

A limit or stop order also carries limitPrice or stopPrice with the same value as price. A market order is filled at the server price, not at price. When the two differ, the sample server recomputes SL and TP from slRaw / tpRaw against its own fill price, then checks them again.

Push Events (Server to Chart)

All push events have the shape { "v": 1, "type": "...", "data": { ... } }.

typeSend whendata
accountBalance, equity or margin changedThe full account object
positionOpenedA new position exists (market fill, pending order filled, second leg of a reverse)The full position
positionModifiedSL, TP or trailing changed, the trailing stop moved, or a partial close reduced the size{ id, ...changed fields }. The chart merges it onto the position with that id.
positionClosedA position was closed completely{ id, closePrice, pnl, reason, symbol, side, qty, openPrice, openTime, closeTime, historyId, commissionTotal, spreadTotal, netPnl, ccy }. reason is manual, sl, tp, margin, reverse or admin_close.
historyAddedRight after a close, full or partialThe full history record. A partial close has reason: "partial".
orderPlacedA pending order was created or modifiedThe full order
orderFilledA pending order triggered. A positionOpened follows.{ id, positionId }
orderCancelledA pending order was removed{ id } for a user cancel, { id, reason } when the server removed it. reason is expired, oco, insufficient_margin or fx_rate_unavailable.
positionsPricedEvery positionPushMs (config.js, default 2000 ms, 0 turns it off), for each online account that holds a symbol whose price moved{ positions: [ { id, currentPrice, pnl, pnlPercent } ], account }
spreadYou called handlers.spreadPush(symbol). Goes to every chart watching that symbol.{ symbol, ...spread rule }
commissionYou called handlers.commissionPush(symbol). Goes to every chart watching that symbol.{ symbol, commission }
sizingYou called handlers.sizingPush(symbol). Goes to every chart watching that symbol.{ symbol, sizing: { minQty, maxQty, qtyStep } }
fxOverridesYou called handlers.fxPush() (all clients) or handlers.fxPush(accountId) (one account){ fxOverrides: { CCY: usdPerUnit } }
refreshA change that no other push covers, or a tab sent requestRefresh{ tabs }, a list of tab keys to reload. null reloads every tab.

positionsPriced keeps profit and loss moving. The chart only receives prices for the symbol it displays, so on a broker connection it does not compute position P&L itself. It shows the currentPrice, pnl and pnlPercent your server sends for every open position, merged by id, and treats the attached account block as a normal account push. pnlPercent is the return on the position margin.

Close Codes

The server closes the socket with an application code when a connection cannot continue. The chart treats every code from 4000 to 4099 as final and does not reconnect. Any other close is treated as a network problem and retried. The limits.* settings are in config.js.

CodeReasonMeaning
4001invalid_token, account_requiredvalidateToken returned false, or it returned true but the chart sent no accountId
4002invalid_account_idThe account id is not usable as a storage key (allowed: lowercase a-z, 0-9, _ and -, up to 64 characters)
4003account_mismatchThe token does not own the requested account
4004account_unavailableThe account record could not be loaded
4008auth_timeoutNo valid auth within limits.preAuthTimeoutMs (default 10000 ms)
4029server_full, too_many_connections, rate_limitlimits.maxConnections reached, limits.maxConnPerIp reached, or one socket sent more than twice limits.msgRateLimitPerSec messages in one second

Error Codes in ack.error

CodeReturned when
token_changed, account_mismatchA request carries a different token or account id than the one the socket authenticated with
unknown_typeNo handler exists for the message type. errmsg holds the type name.
invalid_orderplaceOrder without a symbol or side
invalid_qtyQuantity is not a positive number, is below the minimum or above the maximum, or is not a multiple of the step. errmsg states the limit.
unknown_symbolThe server has no profile for the symbol and the order carried none
spot_long_onlySell to open, or reverse, on a spot symbol
insufficient_marginMargin plus the open commission is more than the free margin
fx_rate_unavailableNo exchange rate between the symbol currency and the account currency, so margin cannot be computed
max_open_positions, max_pending_ordersThe maxOpenPositions or maxPendingOrders cap in config.js is reached. errmsg states the limit.
no_priceThe server has no live price for the symbol, so a market order is not filled
market_closed, market_status_unknownMarket order while the live feed session is closed, or not reported yet (only with enforceMarketHours on)
market_closed_suspended, market_closed_dayoff, market_closed_windowOutside your own trading window (settings/trading-hours.json). Applies to market orders, manual close, reverse and break-even. Carries errparams.
invalid_sl_tpSL or TP on the wrong side of the entry or trigger price. A buy needs SL below and TP above, a sell the reverse. On an open position, SL exactly at the entry price is allowed (break-even).
sl_would_trigger, tp_would_triggermodifyPosition: the level is valid against the entry but already at or past the current market price, so it would fire on the next tick
position_not_found, order_not_foundUnknown positionId or orderId
missing_symbolwatchSymbol without a symbol
internal_errorA handler threw. The sample sends the exception message, or internal_error when there is none.

A limit or stop price on the wrong side of the market is refused with a plain sentence such as buy limit must be below market (1.0852); use a buy stop to enter above. The chart also knows the codes invalid_limit_price and invalid_stop_price if your own server returns a code instead.

You can return your own codes from your checks in handlers.js, for example ctx.fail('symbol_not_allowed') or ctx.fail('declined: KYC pending'). The chart picks the text in this order: your errorMessages override for the code, then the detail after the colon, then its built-in wording, then the raw string.

Keep Your Own Field Names

The server writes both names on every account, position, order and history object it sends, and the chart reads either:
• symbol or instrument
• price or limitPrice / stopPrice (by order type)
• pnl or unrealizedPl on a position, pnl or pl on a closed trade
• margin or marginUsed, freeMargin or marginAvailable
• order status pending is sent as working
• ack: ok or s, data or d. The chart accepts either.
placeOrder accepts symbol or instrument, and price or limitPrice / stopPrice.

Rules to Follow When You Write Your Own Server:
  • Auth first. Bind the socket to the account the token owns. Never trust the accountId in a later request. The sample refuses a mismatch with account_mismatch.
  • One ack per reqId. Reply to every request exactly once, including unknown types, or the chart waits until its timeout.
  • Push after every state change. The ack only answers the tab that asked. The push updates the other tabs and devices of that account.
  • Keep the close order. Send positionClosed, then historyAdded, then account. The chart removes the lines, adds the history row and updates the balance without an extra request.
  • Send only what changed in positionModified. The chart merges the fields onto the position by id. Any extra field you send (for example swap) is stored on the position too.
  • Orders are never replayed. While disconnected, the chart rejects placeOrder, modifyPosition, modifyOrder, closePosition, reversePosition and cancelOrder locally. Only read requests wait for the reconnect.
  • Serialize per account. The sample runs the requests of one account one at a time, so two orders cannot spend the same margin.
  • Server events. Every per-account push also calls onEvent(accountId, type, data) in events.js, even when the customer has no tab open. A refused request calls it with type requestRejected and { request, code, detail, params }.

Chart and Trading Settings

You change the trading UI in three places: FCSAPIChart constructor options (toolbar, symbol search, displayed quote), chart.mountTradingPlatform() options (layout, tabs, action buttons, order form), and your own HTML templates and CSS (columns, labels, colors). These settings only change what the customer sees. Your broker server enforces limits, leverage, spread and commission.

Hide Parts of the Chart

These are FCSAPIChart constructor options. Every enable... switch below defaults to true; set it to false to remove that control. The full list is in the Chart API configuration.

OptionDefaultWhat it does
displayMode'normal''chartonly' hides all chart UI and shows only the chart.
enableSidebar, enableToolbar, enableRangeSelectortrueLeft drawing-tools bar, top toolbar, bottom time range bar.
enableSearch, enableIndicators, enableMultiChart, enableReplay, enableCapture, enableUndoRedo, enableTemplates, enableSettings, enableSymbolInfo, enableChartTypeSelector, enableTimeframeSelectortrueOne switch per toolbar control: symbol search, indicators, multi-chart layouts, replay, screenshot, undo/redo, templates, settings, symbol info, chart type and timeframe.
searchModalTabsnull (all)Which asset tabs the symbol search shows, with their labels: { fav: 'Fav', forex: 'Forex', crypto: 'Crypto' }.
searchAllowSymbols / searchExcludeSymbolsnullOnly these tickers appear in search, or these tickers are hidden. Format EXCHANGE:SYMBOL.
searchAllowExchangesnot setRestricts search per tab. Country tabs (stock, fund, dr, indices) take { COUNTRY: '*' | [exchanges] }. Exchange tabs (crypto, forex, dex) take a list of exchanges. '*' or a missing tab means no limit.

Trading Platform Options

Pass these to chart.mountTradingPlatform({ ... }). All are optional.

OptionDefaultWhat it does
adapter'demo''demo' = paper trading stored in the browser. { type: 'ws', url, token, accountId } = your broker server.
layout'right''right' = order form right, tabs at the bottom. 'sidebar' = all in the right sidebar. 'bottom' = all in the bottom panel. Multi-chart mode always uses the bottom layout.
layoutLockfalsetrue removes the layout switch buttons. When false, the layout the user picked last is saved in the browser and wins over layout.
rightWidth / bottomHeight280 / 220Starting panel sizes in px. A size the user drags to is saved in the browser and wins.
tabsall sixTabs to show, in order; the first opens by default. Built-in keys: positions, orders, history, balance, journal, account. Any other key is a custom tab, filled from a getData request to your server.
positionActionsall true{ partialClose, closeAll, reverse, breakEven }. false removes that action. reverse is always hidden on spot holdings.
orderActionsall true{ modify, cancelAll }: the edit button on a pending order and the Cancel All button.
confirmOrdersfalseShow a confirmation dialog before an order is sent. Its "Don't show again" checkbox is saved per account in the browser.
forceConfirmfalseWith confirmOrders: true, removes the "Don't show again" checkbox so the user cannot opt out.
templatesbuilt-inURLs of your panel HTML: { positions, orders, history, balanceHistory, account, yourCustomTabKey }. The order form has no template; style it with CSS.
onEditPosition / onClosePositionnot setYour function receives the position object when the edit or close button is clicked, so you can open your own modal. Apply the result with chart.tradingModifyPosition() or chart.tradingClosePosition().
orderForm{}defaultQty: starting order size (raised to the symbol minimum when lower). statusTimeout: ms a confirmation or rejection stays on the status line. Default 8000; 0 = never clear.
qtySizingbuilt-inMinimum, maximum and step of the order size, per asset type or per symbol. See below.
errorMessagesbuilt-in EnglishYour text for each rejection code. One map is used by the order form, the positions and orders tabs and the Journal. The sample broker server has a full list in the messages object of settings/error-messages.json.
commissionnullFallback rule for the commission estimate: { type: 'per_trade' | 'per_lot' | 'per_unit' | 'percent', value, min, max }. A rule sent by your server wins.
fxOverrides{}Fallback FX pins for demo or offline use: { PKR: 0.0036 } = USD per 1 unit. With a broker server, the server sends its own fxOverrides and they replace these.

Order Size Limits

qtySizing sets minQty, maxQty and qtyStep for the order form. The input decimals follow qtyStep.

chart.mountTradingPlatform({
    qtySizing: {
        byType:   { forex: { minQty: 0.01, qtyStep: 0.01 }, stock: { minQty: 1, qtyStep: 1 } },
        bySymbol: { 'TSE:7203': { minQty: 100, qtyStep: 100 } }   // board-lot stock
    }
});

Most specific wins: sizing sent by your broker server for the symbol, then the feed profile, then bySymbol, then byType, then the built-in default (forex and commodity 0.01, crypto 0.0001, stock, fund and futures 1). Your server is the final authority and rejects a wrong size with invalid_qty.

Columns: Your Own Panel Templates

Each table reads its columns from the <thead> of the panel template. <th data-field="key"> is the exact JSON key of the record. Remove a <th> and the column is gone; reorder them and the cells follow. Add one for any field your server sends and it shows; a missing value prints a dash. The <th> text is only a label.

<table class="fcs-table">
  <thead><tr>
    <th data-field="symbol">Instrument</th>
    <th data-field="qty">Volume</th>
    <th data-field="pnl">Profit</th>
    <th data-field="swap">Swap</th>   <!-- any field your server sends -->
    <th></th>                          <!-- empty = action buttons column -->
  </tr></thead>
  <tbody class="fcs-positions-tbody"></tbody>
</table>
<div class="fcs-empty-msg" style="display:none">No open trades</div>
Template keyBuilt-in columns (data-field)tbody class
positionssymbol, side, qty, avgPrice, currentPrice, stopLoss, takeProfit, pnl, pnlPercent, openTimefcs-positions-tbody
orderssymbol, type, side, qty, price, stopLoss, takeProfit, status, createdAtfcs-orders-tbody
historysymbol, side, qty, openPrice, closePrice, pnl, commissionTotal, spreadTotal, netPnl, reason, openTime, closeTime, durationfcs-history-tbody
balanceHistorytime, type, note, amount, balancefcs-bh-tbody
custom tab keynone; each column prints the raw valuefcs-datatable-tbody (or any tbody)

If a template URL cannot be loaded, the built-in panel is used. Keep the hook classes the chart fills: fcs-*-tbody, fcs-empty-msg, and in positions fcs-positions-count and fcs-positions-total-pnl. In positions and orders, one empty <th></th> is the action buttons column; which buttons show is set by positionActions and orderActions. Sample templates are in the my-templates folder of the sample broker server.

Labels and Text

  • Tab names: pass tabs as an object, { positions: 'My Positions', orders: 'Pending' }. An empty label keeps the default: Positions, Orders, History, Balance History, Journal, Account Details.
  • Column titles, panel titles, empty messages: plain text in your template.
  • Account values anywhere: put <span data-stat="balance"></span> in any panel template. It updates on every account push. Common keys: balance, equity, margin, freeMargin, marginLevel (shown as %), accountType (Demo or Live).
  • Rejection text: the errorMessages map, for example { insufficient_margin: 'Not enough free margin in your account.' }. Translate the text, never the key.
  • Currency symbol: money is shown in account.currency. To force your own prefix, set displaySymbol on the account record, for example "RS".

Styling Overrides

Load your stylesheet after fcsapi-chart-trading.css and override only what you need. Keep loading ours so new UI in later releases is styled. Most rebrands only need the CSS variables on .fcs-trading-wrapper:

.fcs-trading-wrapper {
  --fcs-bg: #0d0d12;  --fcs-bg-raised: #16161d;  --fcs-bg-header: #1c1c26;
  --fcs-text: #e8e8f0;  --fcs-muted: #8b8b9a;  --fcs-border: #2a2a38;
  --fcs-input: #22222c;  --fcs-hover: rgba(255,255,255,0.06);
  --fcs-buy: #2e7dff;  --fcs-buy-hover: #1a5fd6;    /* buy buttons, positive P&L */
  --fcs-sell: #ff4d6d; --fcs-sell-hover: #d63356;   /* sell buttons, negative P&L */
}
/* Light chart theme */
.fcs-trading-wrapper.fcs-theme-light { --fcs-bg: #ffffff; --fcs-text: #14141c; }

/* Hide the trailing stop row of the order form */
.fcs-order-form .fcs-of-trail-row { display: none; }

On a light chart the wrapper gets the class fcs-theme-light. Every class starts with fcs- (fcs-of-* is the order form); find them with the browser inspector. Style them freely, but do not rename them.

Displayed Ask/Bid Quote

askBid is an FCSAPIChart constructor option. It changes the ask and bid the chart draws, so the customer sees your dealable quote. Candles are not changed. With a broker server you normally leave it unset: the server sends the spread from its own askBid setting for each symbol the chart loads, and fills at the same value. Use the chart option for demo or offline pages.

FieldMeaning
base'feed' (default) starts from the market ask and bid. 'close' builds the quote around the candle close. 'raw' = no change.
spreadPipsWidens each side. 10 = ask +10 pips, bid -10 pips. The pip size comes from the symbol.
spread / spreadPercentThe same in price units, or as a fraction of price (0.001 = 0.1%). Use price units only inside perSymbol.
markupPips / markup / markupPercentShifts both sides together. Can be negative.
perSymbolOverrides keyed by 'EXCHANGE:SYMBOL' (not case sensitive).

For the same setting, price units win over pips, and pips win over percent. Change it at runtime with chart.setAskBid({ base: 'feed', spreadPips: 20 }), always passing a new object. chart.setAskBid(null) returns to the raw feed quote.

Display settings are not security

Hiding a button or limiting the symbol search only changes the page. Your broker server must still check every request: order size (invalid_qty), allowed symbols (symbol_not_allowed), open position and pending order limits (max_open_positions, max_pending_orders), and margin (insufficient_margin).


Broker Economics: Spread, Commission and Swap

This section covers the settings that earn you money: the spread and markup on the feed price, commission per trade, overnight swap and the currency conversion fee. All of them are set in config.js of the sample broker server and applied on the server. The chart shows the customer the same numbers before the trade.

Where each revenue setting lives

A house default goes in config.js. A value that differs per customer goes on that customer's account record (users/user_<id>.json under account in the sample, your own database in production). A customer with no override gets the house default, so you only record the exceptions.

config.js keyWhat it earnsPer-customer overrideLive change
askBidSpread and markup on the quote and on market fills and closesaccount.askBidYes
commissionA fee per trade sideaccount.commissionYes
commissionOnClosetrue charges the fee on open and on close, false on open onlyNoYes
swapOvernight financing on positions held past the daily rolloveraccount.swapFree: true skips swapNo (restart)
fxMarkupA fee on the currency conversion rateNoNo (restart)
brokerCurrencyNothing. It is the currency your income is restated in for your own booksNoYes

"Live change" means the key can be overridden in settings/broker-settings.json and takes effect without a restart. A key placed there replaces the whole config.js value. It is not merged, so copy the complete key, not only the field you change.

Spread and markup: askBid

askBid widens the feed price in your favour. A buy fills above the feed ask, a sell fills below the feed bid. The same rule is sent to the chart when a symbol loads, so the quote the customer sees is the price they are filled at.

buy fill  = feed ask + spread + markup
sell fill = feed bid - spread + markup
FieldMeaning
base'feed' (default) widens the live ask and bid. 'close' builds the displayed quote from the candle close. Fills always use the live market price.
spreadPipsAmount added to each side, in pips. Works on every symbol.
spreadAmount added to each side, in price units. Use it per symbol or per asset class only (10 on BTC is 10 USD, on EURUSD it would be far too large).
spreadPercentAmount added to each side, as a fraction of price. 0.0001 is 0.01% each side.
markup, markupPips, markupPercentShifts both sides in the same direction. May be negative.
byTypeOne rule for a whole asset class, for example forex, crypto, stock.
perSymbolOne rule for one symbol. A bare number is short for { spread: n }.

The amount is applied in full to each side. It is not halved. Examples:

  • spreadPips: 1 on EURUSD (pip 0.0001): feed bid 1.08510 / ask 1.08520 becomes bid 1.08500 / ask 1.08530. The quote is 0.0002 wider.
  • spread: 15 on BINANCE:BTCUSDT: ask +15 and bid -15. The quote is 30 wider.
  • spreadPercent: 0.0005 on a share priced 300.00: 0.15 on each side.

Rules are layered from least to most specific: top level, then byType, then perSymbol, then the customer's own account.askBid (top level, byType, perSymbol). When a more specific layer sets any one of spread, spreadPips or spreadPercent, the other two from lower layers are cleared. So a VIP rule of spreadPips: 1 is never beaten by a house rule of spread: 15. The markup fields work the same way.

The spread earned on a market fill is recorded as spreadRevenue on the position, next to the raw feed price in rawPrice. A pending-order fill records 0. The history row of a closed trade carries the round-trip total in spreadTotal.

Commission

Commission is charged per side and deducted from the balance in the account currency. Pick the base that is fair for the asset: a flat fee suits a forex lot but not one share.

typeHow it is calculatedExample
per_tradeFlat value per order, any sizevalue: 1: 1 share or 1000 shares both pay 1.00
per_lotvalue * (quantity * contract size / 100000)value: 7: 0.1 lot EURUSD pays 0.70
per_unitvalue * quantityvalue: 0.02: 10 shares pay 0.20
percentvalue% of the notional (quantity * contract size * price), converted to the account currencyvalue: 0.15: 0.01 BTC at 60,000 pays 0.90

Optional min and max cap the result. They are in the account currency, so on a book with customers in different currencies either use percent without caps or set commission per customer. Set commission: null for no default fee.

// config.js
commission: {
    type: 'per_lot', value: 1,                            // default for everything
    byType: {
        crypto: { type: 'percent', value: 0.1 },          // 0.1% of notional
        stock:  { type: 'percent', value: 0.1, min: 5 }   // 0.1% with a floor of 5
    },
    bySymbol: {
        'PSX:HBL': { type: 'percent', value: 0.2, min: 25 }
    }
},
commissionOnClose: true

The rule is resolved most specific first: the customer's account.commission, then a commission carried on the instrument profile, then config.commission. Inside a block the order is bySymbol, byType, then the flat rule. A byType or bySymbol entry is a complete rule that replaces the default, so a min on the default does not carry over. The fee is stored on the position (commission) and on the history row (commission for the close side, openCommission, commissionTotal).

Swap: overnight financing

A position held past the daily rollover accrues swap. Rates are swap points (pips) per 1.0 lot per night: negative is charged to the customer, positive is credited. long applies to a held buy, short to a held sell. The rate is looked up in bySymbol, then byType. Otherwise there is no swap.

// config.js
swap: {
    enabled: true,
    rolloverHourUTC: 21,     // nightly accrual time (21:00 UTC)
    tripleWeekday: 3,        // 0=Sun ... 3=Wed: this night counts 3 times
    bySymbol: { 'FX:EURUSD': { long: -0.8, short: 0.3 } },
    byType:   { forex:  { long: -0.5, short: -0.5 },
                crypto: { long: -20,  short: -20 } }
}

One night = points * pip size * contract size * point value * quantity, converted to the account currency. Example: 1 lot long FX:EURUSD at -0.8 is -0.8 * 0.0001 * 100000 = -8.00 per night, and -24.00 on the triple night. Accrued swap is kept in the position's swap field and counts in equity while the position is open. On close it moves into the balance and appears in the history row's swap. The total is recomputed from the number of rollovers the position has lived through, so a restart or a repeated run never charges a night twice.

Conversion fee and your reporting currency

Margin, profit and percent commission are calculated in the symbol's quote currency and converted into each customer's account currency. When the two currencies differ, every conversion rate is multiplied by (1 - fxMarkup): 0.005 takes 0.5%, 0 switches it off.

brokerCurrency is the currency of your own books. Commission and spread income is also restated in it as commissionBase and spreadBase on history rows and trade-log lines, with ccy naming the customer's currency. A book with customers in several currencies then adds up. Run node broker-report.js in the server folder to total commission and spread income across all accounts. It reports in USD unless you pass a currency, for example node broker-report.js PKR.

One rule for the quote and the fill

Change spread through askBid (in config.js, in settings/broker-settings.json or on the account record) and the displayed quote and the fill price move together. If you override getSpread(symbol, ctx) in handlers.js for a dynamic spread, call handlers.spreadPush(symbol) to update every chart watching that symbol. Fills still read askBid, so apply the same rule there. handlers.commissionPush(symbol) does the same for a changed commission.

Two complete setups, with the numbers they produce:
  • Broker A - no spread, 1 per lot on forex, 0.1% on crypto: askBid: { base: 'feed', spreadPips: 0 } and commission: { type: 'per_lot', value: 1, byType: { crypto: { type: 'percent', value: 0.1 } } }
  • Broker A, EURUSD 1 lot: spread 0, commission 1.00 per side
  • Broker A, 0.5 BTC at 60,000: spread 0, commission 30.00 per side
  • Broker B - 1 pip on forex with no commission, raw crypto with 0.1%, shares +10 with 0.1%: the config shown in the code panel
  • Broker B, EURUSD: quote 0.0002 wider (1 pip each side), commission 0
  • Broker B, 0.5 BTC at 60,000: spread 0, commission 30.00 per side
  • Broker B, 100 shares priced 300: quote 20 wider (10 each side), commission 30.00 per side

Risk Controls: Leverage, Margin, Stop-Out and Limits

These are the safety rails of the sample broker server: how much leverage a customer gets, how margin is reserved, when the engine force-closes a losing account, how many orders a customer may hold, and when a market order is refused because the market is closed. The server runs these checks when each request arrives.

Where each control is configured

A property of the instrument or of the house is global and lives in config.js. A property of the customer lives on the account record.

ControlWhereValue in the sample
leverage, leverageByType, leverageBySymbolAccount record, per customerA new sample account gets leverage: 100
tradingModeconfig.jsstock is spot, everything else margin
stopOutLevelconfig.js50
negativeBalanceProtectionconfig.jstrue
maxOpenPositions, maxPendingOrdersconfig.js20 and 20
qtySizingconfig.jsEmpty: the feed profile's own limits apply
enforceMarketHoursconfig.jstrue
tradingHoursconfig.js plus settings/trading-hours.jsonenabled: true

Of these, stopOutLevel, maxOpenPositions, maxPendingOrders, enforceMarketHours, tradingMode and qtySizing can be changed on a running server in settings/broker-settings.json. That file is re-read at most once per settings.recheckMs (5000 ms). Everything else in config.js needs a restart. A key that is not allowed in that file stops the server at boot, with the key named in the error.

Leverage

Leverage differs per customer, so it is stored on the account record, not in config.js.

// users/user_<id>.json -> "account"
"leverage": 100,
"leverageByType":   { "stock": 1, "forex": 100, "crypto": 20 },
"leverageBySymbol": { "PSX:HBL": 100 }

Resolved most specific first: leverageBySymbol[symbol], then leverageByType (the feed type, then the asset class such as forex, crypto, stock), then the flat leverage. A spot symbol always uses 1:1. When a record loads, a flat leverage that is missing, zero or not a number is replaced by 1 (1:1), and an invalid leverageByType entry is dropped, each with a console warning. A typo can over-margin an account, never under-margin it. The leverage used is stamped on every position as leverage. The admin operation POST /admin/settings can also change these three fields.

Margin and free margin

required margin = price * quantity * contract size * point value / leverage
                  (converted into the account currency)
equity          = balance + unrealized P&L + accrued swap
free margin     = equity - used margin

Contract size is 100000 for forex and 1 for everything else, unless the instrument profile sets its own. Examples on a USD account: 1 lot EURUSD at 1.0850 with leverage 100 locks 1,085.00. 0.1 BTC at 60,000 with leverage 20 locks 300.00.

  • An order is accepted only when margin plus the open commission fits in free margin. Otherwise it is rejected with insufficient_margin.
  • A market order is checked again at the server's own fill price. The price sent by the browser is never trusted.
  • A pending order reserves its margin when it is placed, so free margin drops at once and a customer cannot stack orders they cannot afford together. The reservation is released when the order fills, is cancelled or expires.
  • When a pending order triggers and the account can no longer afford it, the order is cancelled with reason insufficient_margin instead of being filled.

Stop-out

stopOutLevel is the margin level, in percent, at which the engine starts closing positions. It acts as the account's stop for positions that have no stop-loss.

margin level = (balance + unrealized P&L of margin positions)
               / margin used by margin positions * 100

The level is computed from live quotes. When it is at or below stopOutLevel, the engine closes the margin position with the largest loss, recalculates, and repeats until the level recovers. Each forced close carries reason: 'margin' on the positionClosed message and on the history row. Example with stopOutLevel: 50: with a balance of 2,000 and 1,085 of margin in use, liquidation starts when the floating loss reaches 1,457.50. The sample has this one threshold and no separate margin-call warning level.

Negative balance protection. With negativeBalanceProtection: true, if a close leaves the account with no open positions and a balance below zero, the balance is reset to zero. The amount you absorbed is written to the balance ledger as an entry of type: 'nbp'. Set it to false only if your customers stay liable for a negative balance.

Spot or margin per instrument

tradingMode decides whether a symbol is leveraged at all. It is global because it describes the instrument, not the customer.

// config.js
tradingMode: {
    byType:   { stock: 'spot' },
    bySymbol: { 'BINANCE:BTCUSDT': 'spot', 'PSX:HBL': 'margin' }
}
ModeLeverageDirectionStop-out
spotAlways 1:1Buy only. A sell-to-open or a reverse is rejected with spot_long_only. Closing is allowed.Exempt. Spot positions are left out of the margin level and are never force-closed.
margin (default)From the account recordBuy and sellApplies

Resolved as bySymbol, then byType, then margin. The map is sent to the chart on the watchSymbol reply so the order form matches the server.

Order limits

  • maxOpenPositions: a new market order is rejected with max_open_positions once the account holds this many open positions.
  • maxPendingOrders: a new limit or stop order is rejected with max_pending_orders once the account has this many working orders.
  • qtySizing: minQty, maxQty and qtyStep in byType or bySymbol, for example bySymbol: { 'TSE:7203': { minQty: 100, qtyStep: 100 } }. The rule for the symbol on screen is sent to the order form on the watchSymbol reply. Fields you leave out fall back to the feed profile. Call handlers.sizingPush(symbol) after a change to update open charts.
  • The server rejects a quantity outside the feed profile's minQty, maxQty or qtyStep with invalid_qty (defaults 0.0001, 100000 and 0.0001). This check reads the feed profile, not qtySizing, so add your own check in handlers.js if the server must enforce your qtySizing values.
  • A pending order on the wrong side of the market is rejected at placement. A buy limit must be below the market and a buy stop above it, the reverse for sells. The customer gets a message such as "buy limit must be below market".
  • An order on a symbol whose quote currency cannot be converted into the account currency is rejected with fx_rate_unavailable.

Market-closed checks

A market execution has to pass two independent gates. Limit and stop orders are never blocked by either gate; they wait for the market to open. Stop-loss, take-profit and stop-out closes fired by the engine are never blocked.

GateConfigured inApplies toReject codes
1. Your own trading windowtradingHours in config.js, rules in settings/trading-hours.jsonMarket orders, closing a position (also a partial close), reversing, and moving the stop-loss to break-evenmarket_closed_suspended, market_closed_dayoff, market_closed_window
2. The exchange session from the live feedenforceMarketHours in config.jsMarket ordersmarket_closed, market_status_unknown

Gate 2 needs no calendar. The session of each symbol arrives with the live WebSocket feed, and a market order is accepted only while that session is the regular market session. If the session has not been reported yet, the order gets market_status_unknown and the customer can retry or place a limit order. Gate 2 runs only when the WebSocket feed is on.

Gate 1 is your own rule per exchange. The exchange is the symbol prefix (PSX:HBL is PSX). An exchange that is not listed is unrestricted, and "*" is a catch-all. The file is re-read while the server runs, within tradingHours.recheckMs. Set tradingHours.enabled: false to skip this gate.

// settings/trading-hours.json
{ "exchanges": {
    "PSX":     { "session": "on", "from": "09:32", "to": "15:30",
                 "tradeoff": ["saturday", "sunday"], "timezone": "Asia/Karachi" },
    "BINANCE": { "session": "on" },
    "FX":      { "session": "on", "tradeoff": ["saturday", "sunday"] }
} }
FieldMeaning
session"on" or "off". "off" closes the exchange completely. Use it for a holiday or an incident; there is no built-in holiday calendar.
from, to"HH:MM" in 24-hour time. Leave both out for all day. to is exclusive. A to earlier than from crosses midnight.
tradeoffNon-trading weekdays, as names ("saturday") or numbers (0 = Sunday to 6 = Saturday).
timezoneIANA zone the window is measured in. Without it the server's local clock is used.

While tradingHours.enabled is true, the rules file is checked at startup: a missing file or invalid JSON stops the boot. A bad edit made later keeps the last good rules and logs the error.

Adding your own checks

Put house rules such as KYC or a blocked symbol list in handlers.js. Run your check, then hand over to the built-in handler so all the checks above still run:

async placeOrder(ctx) {
    if (await myRisk.isBlocked(ctx.accountId)) return ctx.fail('symbol_not_allowed');
    return base.placeOrder(ctx);
}
The server is the authority

The order form previews margin, hides Sell on a spot symbol and refuses a market order when the feed reports the market closed. These are only hints for the customer. Margin, spot mode, the position and order caps and both market-closed gates are checked again on the server when the request arrives, using the server's own live price.

Before you go live:
  • Set leverage on every account record. A missing value is treated as 1:1.
  • Replace the example exchanges in settings/trading-hours.json with your own hours and time zones. Set timezone whenever the server is not in the exchange's country.
  • Keep enforceMarketHours: true for real markets so market orders are refused while the exchange is closed.
  • Decide on negativeBalanceProtection. When it is on, you absorb any loss beyond the customer's balance.
  • In settings/broker-settings.json an override replaces the whole config.js key. Copy the complete key before you edit one field.

Demo Accounts - Practice Trading

A demo account lets a customer trade with practice money. There are two tiers: a browser-only paper account for visitors with no login, and a server-side demo account for signed-up clients. Pick the one that matches who is looking at the chart.

The Two Tiers

Tier 1 - anonymous "try it"Tier 2 - logged-in demo
Chart optionadapter: 'demo'adapter: { type: 'ws', url, token, accountId }
Who it is forA visitor with no account: landing page, product tour, your own evaluation of the UIA signed-up client practising before funding, on their own real settings
State lives inThe browser, in localStorage under the key fcs_demo_tradingYour server or database, as a normal account record
EngineComputed in the browserThe server engine: same SL/TP, margin stop-out, spread, commission, FX and per-user leverage as a live account
SL/TP with the tab closedNo. Stops and pending orders are only checked while the tab is openYes. This is the reason tier 2 exists
Server accountNone. Nothing to come back to on another deviceA normal account with its own id
Starting moneyFixed seed: 10,000 USD, leverage 1:100, account id demo, name Demo AccountWhatever you write on the record, in the record's own currency

Tier 1: Browser-Only Practice

Set adapter to 'demo'. No broker server is needed.

chart.mountTradingPlatform({
    adapter: 'demo'   // paper trading stored in this browser only
});

The chart also uses this adapter when adapter is left out, or when its value is not recognised (for example a ws object with a wrong type). Check the option if a logged-in client sees a 10,000 USD Demo Account.

Tier 1 is acceptable only for anonymous visitors. They have no identity to come back to, so losing the account when browser storage is cleared costs nothing. Once a customer logs in, use tier 2: a client who set a stop loss expects it to fire whether or not the laptop is open.

Tier 2: A Demo Account Is Just an Account

There is no demo subsystem to install and no demo block in config.js. A demo account is a normal account record with a different id. Point the chart at it the same way as a live account:

chart.mountTradingPlatform({
    adapter: {
        type:      'ws',
        url:       'wss://your-broker.example/ws',
        token:     'USER_SESSION_TOKEN',
        accountId: 'usr12_demo'   // the only change vs. this client's live account
    }
});

Everything else already works, because every per-customer condition is read from the account record, not from a mode flag: leverage (pricing.resolveLeverage), commission (resolveCommission), spread (resolveSpreadRule), FX pins (fxOverrides) and the account currency.

  • storage.ensureAccount('usr12_demo') returns that record like any other id.
  • The id appears in listAccountIds(), so core/price-engine.js indexes it exactly like a live account.
  • handlers.js and the files in core/ contain no demo-mode branch.
  • Your validateToken() in auth.js must accept the client's token for the demo id too. Tie the check to the id, as you do for the live account.

The isDemo Flag

isDemo: true on the account record is the only thing that makes an account demo. It is never inferred from the id. usr12_demo is a practice account because its record says so, not because of its name, so your own id scheme works unchanged.

Value on the recordRead as
"isDemo": trueDemo
"isDemo": falseLive
missing, null, "true" (a string), anything elseLive (fail-safe)

The fail-safe points at Live on purpose. The dangerous mistake is treating a real account as practice money, so anything that is not the boolean true reads Live. Write "isDemo": false on your live records anyway, as users/user_usr12.json and users/template.json do, so the field is visible in the record.

Creating a Demo Record

Copy the client's live record, then change the fields below. Copying is the point: the currency, the leverage (leverage, leverageByType, leverageBySymbol), a per-user askBid spread and a per-user commission are inherited, so the practice account behaves like the real one.

FieldSet to
idThe demo id. The sample uses <liveId>_demo. Any id works if it matches the account id rule: lowercase a-z, 0-9, _ and -, 1 to 64 characters
nameThe client's name plus a marker, for example "Client Name (Demo)"
isDemotrue
ownerIdThe live account id, so your portal can link a demo to its owner. The server does not read it
createdAtCreation time in milliseconds since epoch, for your portal to list demos and age them out. The server does not read it
balance, equity, freeMarginThe practice amount
margin, realizedPnl, maxDrawdown0
positions, orders, history, balanceHistory[]. A demo must never inherit the live account's trades

A worked example ships as users/user_usr12_demo.json, next to the live record users/user_usr12.json it was copied from. The code panel shows its fields, with a placeholder name.

Creating the record is your business logic, not the chart's. It is the "Open demo account" button in your client portal. You decide who may have one, how many, with how much practice money and for how long. The sample server has no "create demo account" operation.

Do not rely on the sample's default record. When a valid token connects with an id that has no file yet, the sample storage.ensureAccount() creates a default record: 10,000 USD, leverage 100, name Demo Account. That record has no isDemo flag, so it reads Live and is counted in revenue. Always create demo records yourself with "isDemo": true.

Moving to a Database

There is no demo code path to port, because a demo account is a row like any other. Add one boolean column (for example is_demo, plus owner_id if you want the link to the live client), returned as isDemo in the record your ensureAccount() hands back. Add one WHERE clause, WHERE is_demo = 0, to your revenue and P&L reporting. listAccountIds() stays SELECT id FROM accounts with no demo filter, and so does listRiskRows() if you export it.

The Demo / Live Badge

The account tab's identity strip ends with the account type, so a customer can always tell which money they are trading. Demo is a tinted chip and Live is muted.

Client Name (Demo)  ·  #usr12_demo  ·  PKR  ·  1:1  ·  DEMO
Client Name         ·  #usr12       ·  PKR  ·  1:1  ·  LIVE
WhereHow
Account tab (built in)Rendered through the account panel's data-field mechanism: <span class="fcs-acct-type" data-field="accountType">. It is also in the sample template my-templates/account.html
Any other panelAdd <span data-stat="accountType"></span> to a panel template. The platform fills it with Demo or Live on every account push, the same way it fills data-stat="balance"
Hide the built-in badge.fcs-acct-type { display: none }, for a broker who already shows the account type in their own header
Restyle itOverride .fcs-acct-type-demo and .fcs-acct-type-live in your own CSS

The badge is derived from isDemo with the same fail-safe: anything other than the boolean true renders Live. Keep the account type visible somewhere the customer can reach.

Revenue Reporting Excludes Demo

Demo accounts trade the same engine and stamp the same commission and spread as live ones. Without a filter their practice income would land in your real revenue total. broker-report.js skips every account whose record has isDemo === true and prints what it left out:

$ node broker-report.js

  (excluded 1 DEMO account from revenue: user_usr12_demo.json)

BROKER REVENUE REPORT — all amounts converted to USD

It filters on the flag, never on the id: an account called practice_bob with no isDemo is counted. A record that cannot be parsed is never treated as demo, so a broken file is not silently dropped from the books.

Reset, Expiry and Deletion

These use the Admin API, which is server-to-server only. It listens on adminHost:adminPort from config.js (default 127.0.0.1:6162). The token is your adminToken. Send it from your own backend, never from a browser.

  • Reset a practice balance:POST /admin/reset with { "id", "target", "token" }. It closes every position and cancels every order, then wipes history, the balance ledger, realized P&L and drawdown, lifts any block, and sets the balance to target (default 10000). One reset line is written to the ledger. Name, currency and leverage are kept. It replies { "ok": true, "id", "balance", "cleared": true }.
  • Reset does not check isDemo. It wipes whatever account id you send, and creates a default record if the id does not exist. Check the flag in your own backend before you call it.
  • Before deleting a demo: call POST /admin/close_all with { "id", "token" } so the account ends flat. Deleting a flat account cannot leave an orphaned position in the price engine.
  • Deleting and expiry are yours:storage.js has no delete function and the server has no expiry job. createdAt is on the record so your portal can age demos out.
curl -X POST http://127.0.0.1:6162/admin/reset \
  -H "Content-Type: application/json" \
  -d '{"id":"usr12_demo","target":100000,"token":"YOUR_ADMIN_TOKEN"}'
listAccountIds() Must Keep Returning Demo Ids

The price engine builds its list of stop loss, take profit, pending order and stop-out levels from listAccountIds() (or listRiskRows() if you export it). That scan is what makes stops fire for an account with no tab open.

If you filter demo accounts out of it, for example with a stray WHERE is_demo = 0, every demo client gets the tier 1 defect back: stops that only work while the browser is open. The same applies to an "only accounts that are online" filter.

Filter demo out of your reporting, never out of the engine's account scan.

What the Broker Configures:
  • Which tier:adapter: 'demo' for anonymous visitors, or a ws adapter with the demo accountId for logged-in clients
  • The demo record: a copy of the live record with "isDemo": true, ownerId, createdAt, the practice balance and empty trade lists
  • Authentication:validateToken() in auth.js accepts the client's token for the demo id
  • Your portal: who may open a demo, how many, how much practice money, when it is reset and when it expires
  • Your reporting: exclude isDemo accounts from revenue, as broker-report.js does
  • Nothing in config.js: there is no demo setting. Spread, commission, leverage and FX apply to demo accounts exactly as to live ones

Going to Production: Checklist

The sample broker server runs out of the box so you can test the chart with trading. Work through this list before real customers and real money use it. Each item says what to change and why.

Settings (config.js)

  1. Set your own FCS API keys. Fill wsKey and restKey, or export FCS_WS_KEY and FCS_REST_KEY (the environment value wins).
    Reason: the two keys are not interchangeable and the server refuses to start without a WebSocket key. A demo WebSocket key streams at most 5 symbols, and a demo REST key does not return the FX rates used for currency conversion.
  2. Set your real pricing. Review commission, commissionOnClose, askBid (spread), swap and fxMarkup.
    Reason: the server enforces these values, and they are what your customers are charged. The shipped values are examples.
  3. Set your instrument rules. Review tradingMode (which symbols are spot and which are margin) and qtySizing (min, max and step per order).
    Reason: a spot symbol is long-only, uses 1:1 leverage and is never closed by stop-out, so a wrong class changes the margin your customers pay.
  4. Set your risk limits. Review stopOutLevel, negativeBalanceProtection, maxOpenPositions and maxPendingOrders.
    Reason: the server enforces them and rejects past the cap with max_open_positions or max_pending_orders.
  5. Set your trading hours. Decide enforceMarketHours and tradingHours.enabled, then put your own exchanges in settings/trading-hours.json with a timezone for each.
    Reason: the shipped rules (PSX, BINANCE, FX) are examples. Without timezone the window is measured on the server clock.
  6. Set your currencies. Store currency on every account record, set brokerCurrency to the currency of your own books, and pin a rate in fxOverrides for any currency that has no live rate.
    Reason: an order whose currency cannot be converted is rejected with fx_rate_unavailable instead of being margined at a wrong rate.
  7. Set the connection limits. Review limits.behindProxy, limits.maxConnections, limits.maxConnPerIp and limits.msgRateLimitPerSec. Set behindProxy to true only when a reverse proxy on the same machine terminates SSL.
    Reason: behind a proxy every customer looks like 127.0.0.1, so without it the per-IP cap applies to all customers together.
  8. Keep the live position push on. Review positionPushMs (default 2000, 0 disables it) and positionSaveMs (default 10000).
    Reason: on a WebSocket connection the chart does not calculate position P&L. This push keeps it moving.
  9. Turn off debug output and keep the audit log. Set feedDebug to false and ship the tradeLog lines from logs/ to your own database or compliance store.
    Reason: the file logger is a sample. Every order, fill, close and rejection should end up somewhere you can audit.

Your Business Files

  1. auth.js: replace the development token check. Make validateToken() verify a JWT or look up a session, and confirm the token belongs to the requested account id.
    Reason: a check that is not tied to the account id lets a customer connect with someone else's id.
  2. storage.js: replace the JSON files with your database. Keep the same state shape. Every function may be async. listAccountIds() must return every account, including demo accounts and accounts that are offline.
    Reason: the JSON file storage is a development fixture that blocks the process while it reads, and no stop-loss can fire during a block. An account missing from the list only has working SL/TP while its owner has a tab open.
  3. storage.js: export listRiskRows(). Return, in one query, the open positions and working orders of all accounts, plus each account's askBid and currency.
    Reason: without it the price engine loads every account in full every 60 seconds and again every 15 seconds. In our load test at 1000 concurrent traders it gave 8.8 times the throughput.
  4. Use valid account ids. Ids must match /^[a-z0-9_-]{1,64}$/ (lowercase). Map an email address or an uppercase UUID to a matching id before the chart connects, and keep isValidAccountId() in your storage layer.
    Reason: a bad id is rejected at login with invalid_account_id and close code 4002.
  5. handlers.js: add only your own rules. Run your check (risk, KYC, blocked account), then call the matching base function. Read the account from ctx.state and ctx.account.
    Reason: an unedited copy of a base function hides later fixes from you. A second storage.ensureAccount() call inside a handler gives you a second object, and its changes are lost.
  6. events.js: connect your alerts. Send email, push or webhook from onEvent(accountId, type, data), and handle the requestRejected type. It may be async. Keep it fast.
    Reason: it fires for stop-loss, take-profit and stop-out closes even when the customer has no tab open.
  7. Decide the per-user values. Carry leverage, leverageByType, leverageBySymbol, commission, askBid, currency and isDemo on the account record. Set isDemo to false explicitly on live accounts.
    Reason: isDemo on the record is the only thing that marks an account as demo, and broker-report.js uses it to leave demo accounts out of revenue.
  8. Maintain account.maxDrawdown yourself.
    Reason: the chart only displays this value. The sample server does not calculate it.

Deployment and Security

  1. Run behind TLS. Put a reverse proxy such as nginx or Caddy in front and use a wss:// address in the chart adapter url, never plain ws://.
    Reason: the login token and every order travel over this socket.
  2. Lock down the Admin API. Set a strong ADMIN_TOKEN through the environment, keep adminHost on 127.0.0.1 or an internal interface, and never open adminPort to the public.
    Reason: this endpoint can add balance to any account. The shipped token is a placeholder.
  3. Run one instance under a process supervisor. Start it with pm2 start pm2_server.config.js and keep fork mode with one instance.
    Reason: the server exits on an unexpected error so that the supervisor restarts it. The connection registry, the per-account lock and the trigger book live in the memory of one process, so two instances behind a load balancer are not safe.
  4. Prepare your chart page. Start from the example page, pass the token and account id from your own login, and remove every setting its comments mark as testing only. Host error-messages.json at a public path of your own and pass its messages object as errorMessages. Do not serve the rest of the server folder (config.js, users/, logs/) over HTTP.
    Reason: the browser only needs the message file. Everything else is private.
  5. Change live values without a restart. Put spread, commission and risk cap changes in settings/broker-settings.json and keep your baseline in config.js.
    Reason: a restart drops every customer socket. The JSON file is read again after you save it.
  6. Upgrade by replacing core/ and dist/. Do not edit files in core/.
    Reason: a release never touches your settings and business files, so an upgrade is a file replace and a restart.
Golden Rule

The chart never decides anything about execution. Validation, margin, SL/TP matching, live-price fills and persistence all happen on your server. The chart sends what the user wants to do and displays what your server answers.

Final Tests Before Launch:
  • Open the chart in two tabs and on a phone. Place an order in one. The others must update within about 100 ms.
  • Set a stop-loss, close every browser tab, and confirm the position still closes when the price is reached.
  • Disconnect the network and reconnect. The chart must receive a fresh snapshot and show the current positions.
  • Send an order that must be rejected (too large, market closed). Check that the customer sees your own wording.
  • Credit a deposit with POST /admin/balance. The balance on the open chart must change without a page reload.
  • Run node broker-report.js and confirm that demo accounts are listed as excluded from revenue.

Troubleshooting Common Issues

The problems broker developers meet most often, with the cause and the fix. Start with the server log: the server prints a clear line for every start-up failure and every reloaded setting.

The Server Does Not Start

IssueCauseSolution
Exits with FCS_WS_KEY is emptyNo WebSocket feed key. A live feed is required because SL/TP and stop-out fire on live ticks.Set wsKey in config.js or export FCS_WS_KEY.
Exits with Port 6161 is already in use or Admin port 6162 is already in useAnother copy of the server is still running.Stop the other process, or start on another port: PORT=6171 node server.js (or ADMIN_PORT=6172 for the admin port).
Exits with a settings/broker-settings.json errorThe JSON is malformed, or the file contains a key that cannot change at runtime (the message says cannot be changed at runtime), for example port, limits, wsKey or fxOverrides.Fix the JSON, or move that key to config.js and restart. Only these keys may appear in the file: askBid, commission, commissionOnClose, tradingMode, qtySizing, stopOutLevel, maxOpenPositions, maxPendingOrders, enforceMarketHours, checkTradeable, brokerCurrency. Keys starting with _ are ignored.
Exits with trading-hours rules file is invalid or says the file does not existtradingHours.enabled is true but settings/trading-hours.json is missing or malformed.Fix the JSON or the path in tradingHours.file, or set tradingHours.enabled to false.
Process exits later with FATAL: unhandled promise rejection or FATAL: uncaught exceptionAn error escaped from your own code outside a handler (a timer, a webhook). The server exits on purpose instead of running half broken.Catch the error in your code, and run under PM2 so the process restarts by itself.

The Chart Cannot Log In

When the server refuses a connection it closes the socket with a code between 4000 and 4099. The chart treats these codes as final and stops reconnecting until the page is reloaded. Other closes (network drops) reconnect automatically, starting after 1 second and backing off up to 30 seconds. The close code is printed in the browser console on a line starting with [WsAdapter].

IssueCauseSolution
Close code 4001 invalid_tokenvalidateToken() in auth.js returned false: the token is wrong, expired, or not valid for that account.Issue a fresh token from your login and reload the page. Check the rule in auth.js.
Close code 4001 account_requiredvalidateToken() returned true but the chart sent no accountId.Pass accountId in the adapter, or return { accountId } from validateToken().
Close code 4002 invalid_account_idThe account id does not match /^[a-z0-9_-]{1,64}$/. Uppercase letters, dots and @ are not allowed.Map your email or UUID key to a lowercase id before the chart connects.
Close code 4003 account_mismatchThe token belongs to a different account than the accountId the chart asked for.Send the account id that the token owns.
Close code 4004 account_unavailableThe account record could not be loaded. With the sample JSON storage this means the user file was unreadable and was renamed to user_<id>.json.corrupt.<timestamp>.json.Read the server log, restore a valid record, then remove the .corrupt file.
Close code 4008 auth_timeoutNo valid auth message arrived within limits.preAuthTimeoutMs (default 10 seconds).Send auth as the first message. The chart adapter does this for you.
Close code 4029 for many customers at oncetoo_many_connections: the server sits behind a proxy and counts every customer as one IP. server_full: limits.maxConnections was reached. rate_limit: one socket sent more than twice limits.msgRateLimitPerSec messages in one second.Set limits.behindProxy to true and make the proxy send X-Forwarded-For, or raise the limit that was hit, then restart.
An unknown user logs in and gets a 10,000 USD accountThe sample ensureAccount() creates a default record for any new id, and the development token check accepts it.Replace auth.js and storage.js before going live.

Orders and Prices

IssueCauseSolution
Market orders fail with no_priceThe server got no live price for the symbol from the WebSocket feed or the REST fallback.Check both FCS API keys (a demo WebSocket key streams at most 5 symbols). Set feedDebug to true and read the [feed] lines.
Market orders fail with market_status_unknownThe feed has not reported the session for this symbol yet. The server never fills on a guess.Retry after a moment, or place a limit order.
Requests fail with market_closed, market_closed_window, market_closed_dayoff or market_closed_suspendedThe first comes from the live feed session (enforceMarketHours) and blocks market orders. The other three come from your own settings/trading-hours.json and block market orders, closes and reverses.Check the rule and the timezone for that exchange. Limit and stop orders are never blocked.
Orders fail with fx_rate_unavailable, or the order form is disabled with a "no exchange rate" messageThere is no rate to convert the symbol currency into the account currency. A demo REST key does not return these rates.Use your real restKey, or pin the rate in fxOverrides as USD per 1 unit, for example { PKR: 0.0036 }.
An order fails with not_connectedThe socket was down. The chart never queues orders, changes or closes while disconnected.Wait for the reconnect and send the order again.
A spread or commission change has no effectA config.js edit needs a restart, or the same key in settings/broker-settings.json overrides it. An override replaces the whole value. It is not merged.Edit the JSON file for a live change and include every sub-key you still need. The server log names the sub-keys that were dropped.
A customer sees the default English text instead of your wordingerrorMessages was not passed to mountTradingPlatform, or a key in the file was renamed.Pass the messages object from error-messages.json. Change only the values, never the keys.
Leverage shows 1:1 for an accountThe record had an invalid leverage (0, negative or not a number). The server falls back to 1 and logs a warning.Store a positive number in the account record.

Sync, Stops and Performance

IssueCauseSolution
Position P&L freezes when the chart shows another symbolThe positionsPriced push is missing. positionPushMs is 0, or your own server does not send it.Set positionPushMs to 1000 or 2000, or send positionsPriced yourself.
A second tab or device does not updateYour handler changed the account state without a broadcast.Call ctx.broadcast(type, data) after every state change.
Changes made in a handler are lostThe handler called storage.ensureAccount() again and changed that second object.Change ctx.state and save that same object.
SL/TP only fires while the customer has the chart openlistAccountIds() or listRiskRows() leaves out that account (a demo filter or an "online only" filter).Return every account from both functions. Filter demo accounts in reporting only.
Orders get slow and stops fire late as accounts growThe JSON file storage reads from disk synchronously and blocks the single server process.Move to an async database storage.js and export listRiskRows().
The Admin API answers unauthorized or not_foundThe token field in the JSON body is missing or wrong, or the request is not a POST to /admin/<function name in admin.js>.Send POST with a JSON body that includes token. A business refusal such as insufficient_free_margin comes back as HTTP 200 with ok: false.
Need Help?

Contact support at support@fcsapi.com. Include the core version printed at boot (the line starting with FCS broker core v), the server log lines around the problem, the WebSocket close code and any browser console errors. Never send your admin token or customer data.

Debugging Tips:
  • Read the boot log first. It prints the core version, the active WebSocket transport, and either the live overrides in force or the settings you can change without a restart.
  • Set feedDebug: true in config.js to see the price feed connect, join symbols and fall back to REST.
  • Open the audit log in logs/. Every rejected order is written there with the account, the symbol and the error code.
  • Open the browser console (F12). Lines starting with [WsAdapter] show failed logins and the close code.
  • Read the error field of a rejected request for the machine code and errmsg for the detail.
  • Handle the requestRejected type in events.js to see every refusal on the server side.

Terms & Disclaimer

What the trading chart and the sample broker server are, and what stays your responsibility as the broker.

Sample Code

The sample broker server is reference code. It shows how a broker back end talks to the FCS Trading Chart. It ships with development defaults: JSON file storage, a development token check, and example prices, limits and trading hours. It is not a finished brokerage system. Review, adapt and test it before any live use.

Your Responsibilities

You operate the server, so you are responsible for everything it does with customer money. This includes order execution, fills, margin and stop-out, account balances, deposits and withdrawals, data storage and backups, security of the server and its keys, and your licensing, regulatory and reporting duties in every country you serve. The chart is a display and an input form. It makes no trading decision.

Market Data and Prices

Prices come from multiple exchanges and liquidity providers. We work to keep them accurate, but data can be delayed, interrupted or different from other sources. Any order your server fills at a price is your decision as the broker.

Trading Disclaimer

Charts, indicators and trading tools are provided for informational purposes only. They are NOT trading advice. We bear NO responsibility for trading losses, missed fills, wrong settings or downtime that result from using the chart or the sample broker server.

License

The broker server is provided under a commercial license. It gives you a non-exclusive, non-transferable right to use, modify and deploy it for your own brokerage operations. It may not be resold, redistributed as a standalone product or sublicensed. The software is provided "AS IS", without warranty of any kind. For white-label or redistribution terms contact sales@fcsapi.com.

Important Notice

Trading leveraged products carries a high risk of loss. Have your own legal, compliance and security teams review your integration before you accept customer funds. This documentation is technical guidance, not legal or financial advice.

Key Points:
  • The sample broker server is sample code, not a ready-made brokerage
  • Your server owns money, execution and risk. The chart only displays it
  • You are responsible for compliance, licensing and customer funds
  • You are responsible for securing keys, tokens and the Admin API
  • Prices may differ from other market sources
  • No liability for trading losses, missed fills or downtime
  • The software is provided "AS IS", without warranty
By using the FCS Trading Chart and the sample broker server, you agree to our Terms of Service and Privacy Policy.