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.

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 / databaseWhat the Broker Receives
| Item | Purpose |
|---|---|
fcsapi-chart.jsfcsapi-chart.css | The chart library. Load it on every page. |
fcsapi-chart-broker.js | Broker API extension: trade lines, markers, candle data and chart events. |
fcsapi-chart-trading.jsfcsapi-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.html | Working example: chart and built-in trading UI connected to the sample server. |
test-broker.html | Working 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.jsandevents.js. Thecore/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.
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.
- 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,sidebarandbottom - 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.js | Environment | Used for |
|---|---|---|
wsKey | FCS_WS_KEY | WebSocket 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. |
restKey | FCS_REST_KEY | REST 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 startnpm 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
- In the order form on the right, keep the Market tab selected.
- Enter a size, choose the Buy side and press the BUY button.
- Confirm the order in the dialog, if it appears (the example page sets
confirmOrders: true). - The position appears in the Positions tab and its entry line is drawn on the chart.
- 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 see | Cause and fix |
|---|---|
Server exits with FCS_WS_KEY is empty | No WebSocket key. Set wsKey in config.js or export FCS_WS_KEY. |
Server exits with Port 6161 is already in use | Another 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_closed | The 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_suspended | Your 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_unknown | The 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_unavailable | The 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. |
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.
config.js- every setting: keys, port, commission, spread, risk limitssettings/-broker-settings.json(spread, commission and risk-limit overrides) andtrading-hours.jsonare re-read on save with no restart;error-messages.jsonholds customer-facing rejection text, which your page loads and passes tomountTradingPlatformaserrorMessageshandlers.js- your business logic. Every chart request already works fromcore/; override only the ones you changestorage.js- where account data lives (replace the JSON files with your database)auth.js- how a token is checked against an account idadmin.jsandevents.js- back-office operations (deposit, withdraw, close all) and your alert hookserver.js- the process entry point. Add only your own process setup here (logging, crash reporting, health checks)core/- do not edit. A new release replacescore/anddist/;server.jsand 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 Platform | Path B - Broker API Only | |
|---|---|---|
| Example page | test-trading-platform.html | test-broker.html |
| Scripts | chart + broker + trading | chart + broker only |
| Entry point | chart.mountTradingPlatform({ ... }) | chart.addDrawing(), chart.addMarker(), chart.on() |
| You build | Nothing 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 backend | The 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 needed | Yes: the sample broker server, modified for your backend (or the 'demo' adapter for evaluation). | Your existing system. |
| Stop Loss, Take Profit, stop-out | Handled by the price engine in the sample server's core/ folder. | Handled by your existing system. |
| Best for | A 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:
| Adapter | Config | Use |
|---|---|---|
| Demo | adapter: 'demo' | No server. Paper trading kept in browser storage. For evaluating the UI. |
| WebSocket | adapter: { 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
onEditPositionandonClosePositionhooks open your own modal instead of the built-in form, and thetemplatesoption 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.
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().
- 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/andmy-templates/. - The guides in the
document/folder.README.mdexplains the server file by file and marks which files you may edit.HANDOFF.mdlists what changed in each version and where to look when something breaks. - The broker guide.
broker-documentation.htmlfrom the package has the same content as this page. - The example page.
test-trading-platform.htmlshows 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
- Copy the package to a working folder on your computer and put it under git, so you can see and undo every change.
- Save the instruction file from the right panel as
AGENTS.mdin that folder. Some tools read another name, such asCLAUDE.md; use the one your tool reads. The same text is in the package asdocument/AI_INSTRUCTIONS.md. - 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.
- Answer its questions in plain words.
- 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.jsorsettings/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.mdand never anything insidecore/. Your own work then survives a newercore/. - 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.jsaccepts 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.
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
| Mode | Value | Use |
|---|---|---|
demo | adapter: '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. |
ws | adapter: { 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 option | Type | Default | Description |
|---|---|---|---|
type | string | - | Must be 'ws'. |
url | string | required | Your broker WebSocket endpoint. The sample broker server listens on port 6161. Use wss:// in production. |
token | string | null | The user session token. It is sent in the first message and checked by validateToken() in auth.js on your server. |
auth | object | null | Custom auth payload, for example { apiKey, sig }. It is passed to validateToken() as the second argument. |
accountId | string | null | The 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. |
reconnect | boolean | true | Reconnect after a drop. The wait starts at 1 second and doubles up to 30 seconds. |
heartbeat | number | 30000 | Ping interval in milliseconds. Keeps idle connections alive behind proxies and load balancers. |
requestTimeout | number | 15000 | Milliseconds 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.
| Option | Type | Default | Description |
|---|---|---|---|
adapter | string | object | 'demo' | Data source, see the tables above. |
layout | string | 'right' | 'right' = order form on the right, tabs along the bottom. 'sidebar' = everything in the right sidebar. 'bottom' = everything in the bottom panel. |
rightWidth | number | 280 | Starting width of the right panel in pixels. |
bottomHeight | number | 220 | Starting height of the bottom panel in pixels. |
layoutLock | boolean | false | true hides the layout switch buttons and always uses your layout. |
tabs | array | object | all six built-in tabs | Which 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. |
positionActions | object | all true | Set 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. |
orderActions | object | all true | Set any to false to hide it: modify (edit price / SL / TP before the fill), cancelAll (Cancel All button). |
confirmOrders | boolean | false | Show a confirm dialog before an order is sent. Its "Don't show again" checkbox is remembered per account in the browser. |
forceConfirm | boolean | false | With confirmOrders: true: always confirm and hide the "Don't show again" checkbox. For compliance. |
orderForm | object | {} | 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. |
errorMessages | object | {} | 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. |
templates | object | {} | 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. |
onEditPosition | function | null | Called 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 }). |
onClosePosition | function | null | Called with the position object when the user clicks close, instead of the built-in close. Close with chart.tradingClosePosition(id). |
commission | object | null | Fallback 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. |
fxOverrides | object | {} | 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. |
qtySizing | object | built-in per asset type | Order 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,rightWidthandbottomHeighton the next visit. WithlayoutLock: trueyourlayoutalways 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.
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.
- 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 anyaccountIdlets one customer open another customer's account. - Use a
wss://URL so the token and the orders travel encrypted. - Wire
symbolChangetochart.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).
| Event | Fires when | Callback data |
|---|---|---|
ready | Once, when the chart has loaded its first candles | { symbol, period, candles }. candles is the number of bars loaded |
tick | On 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 |
symbolChange | The user or your code changes the symbol | { symbol, period, chartIndex } |
periodChange | The user or your code changes the timeframe | { symbol, period, chartIndex } |
drawing | The 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
| Method | Returns | What it does |
|---|---|---|
addDrawing(options) | Promise: the drawing id, or null on failure | Adds a drawing. If one of your drawings already has the same id, that drawing is updated instead |
updateDrawing(id, options) | true / false | Changes 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 / false | Removes one of your drawings |
getDrawings(type, source) | Array | Lists drawings. type filters by drawing type (or null). source is broker (default, only yours), gui (drawn by the user) or all |
getAllDrawings(type) | Array | Short form of getDrawings(type, 'all') |
clearDrawings(type, source) | Number removed | Removes 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 null | One bar: { open, high, low, close, volume, timestamp }. Index 0 is the latest bar, 1 is one bar back |
getCandles(count, offset) | Array | Several bars, newest first. offset skips that many bars from the latest |
getPip() | Number | Smallest price step of the current symbol, for example 0.00001 on a 5-decimal EURUSD |
getSymbol(), getPeriod() | String | Current 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 / false | Scrolls 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.
| Option | Description |
|---|---|
type | horizontal, vertical, trendline, ray, arrow, circle, fibonacci, note, pricelabel, tradeline |
id | Your own id, used to update or remove the drawing. Generated when you leave it out |
price, price2 | Price of point 1 and point 2. A horizontal line needs only price |
index, index2 | Candle position counted from the right: 0 is the latest bar |
timestamp, timestamp2 | Unix time of point 1 and point 2. Used instead of index when both are given |
color, lineWidth, style | Line color (hex), thickness in pixels, and solid or dashed |
text, textColor, textSize, textPosition | Label on the line. Position is above-left, above-center, above-right, below-left, below-center or below-right |
tooltipTitle, hoverText, tooltipMode | Tooltip shown when the user hovers the drawing. Use \n for a new line in hoverText. tooltipMode: 'off' hides it |
lock | true: the user cannot drag or delete the drawing |
lockDelete | true: the user can drag the drawing but cannot delete it |
settingsLock | true: no settings panel and no right-click menu for the drawing |
chartIndex | Target 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-1tonumber-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.
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.
- Attach your
chart.on()listeners right after you create the chart, before the data loads. CallgetCandle(),getPip()andaddDrawing()afterreadyhas 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 astrade-sl, so callingaddDrawing()again updates the line instead of adding a second one. Registerchart.on('drawing', ...)before the data loads, so the chart links the saved drawings to your ids. lockDeleteis not saved with the drawing. After a page refresh, calladdDrawing()again with the sameidand options to set it again.- Markers are saved per symbol in the browser too.
addMarker()does not replace a marker with the sameid, so callremoveMarker(id)first when you draw it again. - The
drawingevent reports only what the user does. Your ownaddDrawing(),updateDrawing(),removeDrawing()andclearDrawings()calls do not fire it. - The
tickevent needs live prices, so keep the chart optionenableSocketon (the default). - In a multi-chart layout the data and drawing methods take an optional
chartIndex, for examplechart.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 thewsWebSocket package. - Two FCS API keys, set in
config.jsor as environment variables.wsKeyis the WebSocket streaming key (live ticks).restKeyis 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:6161npm 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 / folder | You edit it? | What it is for |
|---|---|---|
server.js | Rarely | The 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.js | Yes | Every 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.js | Yes | Your 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.js | Yes | Where account data lives. The sample writes one JSON file per user, users/user_<id>.json. Replace it with queries to your database. |
auth.js | Yes | One function, validateToken(). It decides if a connection may use the account it asked for. Replace the sample check with your sessions or JWT. |
admin.js | Yes | Back-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.js | Yes | One onEvent(accountId, type, data) hook, fired on every account event. Use it for alerts, email and webhooks. |
settings/ | Yes | broker-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.js | Run it | node 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.js | Optional | An 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.js | Optional | The PM2 process file for production (see below). |
core/ | No | The 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/ | Data | The per-user JSON files written by the default storage.js. |
logs/ | Data | The 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.
| Variable | config.js key | Description |
|---|---|---|
PORT | port | WebSocket port the chart connects to. Default 6161. |
FCS_WS_KEY | wsKey | FCS WebSocket streaming key. The server will not start with an empty wsKey. |
FCS_REST_KEY | restKey | FCS REST key for the latest price. |
ADMIN_PORT | adminPort | Port of the server-to-server admin endpoint. Default 6162. |
ADMIN_HOST | adminHost | Address the admin endpoint binds to. Default 127.0.0.1 (this machine only). |
ADMIN_TOKEN | adminToken | Secret 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.
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.
- Feed keys: set your own
wsKeyandrestKey. 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://, notws://. - limits.behindProxy: set
truewhen a proxy terminates SSL in front of the server,falsewhen the chart connects to the port directly. It decides how the per-IP connection cap reads the customer IP. - feedDebug: set
falseinconfig.jsonce 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
- The chart sends a request, for example
placeOrder, with a uniquereqId. core/protocol.jschecks that the socket is authenticated and that the token andaccountIdmatch the ones used at login. It then calls the handler with the same name astype. An unknown type is rejected withunknown_type.- The account record is loaded once with
storage.ensureAccount(accountId)and passed to the handler asctx.state. - Your
handlers.jsruns your own checks. The base handler incore/handlers-base.jsthen does the mechanics: re-price at the live quote, check margin, build the position. - The changed record is written back with
storage.save(accountId, state). ctx.ok(payload)answers the request.ctx.broadcast(type, data)pushes the change to every tab and device of that account.- 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)
| Field | Type | Meaning |
|---|---|---|
id | string | Account id. Must be the same accountId the chart logs in with. |
name | string | Display name. |
isDemo | boolean | Optional. true marks practice money and the account panel shows DEMO. Missing or false shows LIVE. |
balance | number | Closed balance. |
equity | number | Balance plus floating P&L and accrued swap of open positions. |
currency | string | Account currency code, for example USD or PKR. All money fields are in this currency. |
displaySymbol | string | Optional. Your own currency label shown before amounts, for example RS. |
margin | number | Margin in use by open positions and pending orders. |
freeMargin | number | Equity minus margin. |
leverage | number | Flat leverage, 100 means 1:100. Zero, negative or non-numeric is treated as 1. |
leverageByType | object | Optional, per asset class, for example { "stock": 1, "forex": 100, "crypto": 20 }. |
leverageBySymbol | object | Optional, per symbol, for example { "PSX:HBL": 100 }. Wins over both fields above. |
askBid | object | Optional per-user spread. Same shape as config.askBid and layered over it for this user only. |
commission | object | Optional per-user commission, { type, value, min, max }. Wins over the per-symbol and default commission. |
fxOverrides | object | Optional pinned FX rates for this user, USD per 1 unit of the currency. Layered over config.fxOverrides. |
swapFree | boolean | Optional. true skips overnight swap for this account. |
realizedPnl | number | Lifetime closed P&L. |
maxDrawdown | number | Peak-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.
| Field | Type | Meaning |
|---|---|---|
id | string | Unique position id. Ids must be unique across positions, orders and history. |
symbol | string | For example EURUSD or BINANCE:BTCUSDT. |
side | string | buy or sell. |
qty | number | Lots or units. |
avgPrice | number | Fill price. |
currentPrice | number | Live price on the exit side of this user's quote, spread included: a buy is valued at the bid. |
stopLoss, takeProfit | number or null | Levels. The chart draws the lines. |
trailing | object or null | Optional trailing stop, { mode, value }. The server engine moves stopLoss for it. |
pnl | number | Floating P&L in account currency. |
pnlPercent | number | Return on margin, pnl / margin * 100. Not the price change percent. |
commission | number | Open-side fee charged. |
margin | number | Capital this position locks. |
notional | number | Position size in money: qty x contract size x price. |
swap | number | Overnight financing accrued. Negative is charged, positive is credited, 0 when no swap rate is set. |
convCurrency, convRate | string, number | Quote currency of the symbol and the rate used to convert it to the account currency (1 when they are the same). |
leverage | number | Leverage this position was margined at (1 for spot). |
spot | boolean | true means owned outright: no margin stop-out, and the Reverse action is hidden for that row. |
openTime | number | Open 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)
| Field | Type | Meaning |
|---|---|---|
id, symbol, side, qty | Same as on a position. | |
type | string | limit or stop. |
price | number | Trigger price. |
stopLoss, takeProfit | number or null | Levels applied to the position when the order fills. |
status | string | working while the order is pending. |
createdAt | number | Placement time, milliseconds since epoch. |
expiresAt | number or null | Good-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. |
oco | boolean | When 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, leverage | A 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.
| Field | Type | Meaning |
|---|---|---|
symbol, side, qty | Required. A missing symbol or side is rejected with invalid_order, a bad quantity with invalid_qty. | |
type | string | market, limit or stop. |
price | number | Trigger 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, takeProfit | number or null | Absolute levels. |
slRaw, tpRaw, clientPrice | object, number | What 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, oco | number, boolean | Pending orders only, see the table above. |
profile | object or null | Symbol profile from the data feed. Used only when the server has not cached its own yet. |
history[] (closed trades)
| Field | Type | Meaning |
|---|---|---|
id, positionId | string | History row id and the id of the position it closed. |
symbol, side, qty | What was closed. On a partial close qty is the part that was closed. | |
openPrice, closePrice | number | Entry and exit price. |
pnl | number | Realized P&L before fees. Your spread is already inside it, because it is part of the fill prices. |
commission | number | Close-side fee only. |
openCommission | number | Open-side fee that belongs to this close. |
commissionTotal | number | openCommission + commission, the full round-trip fee. |
spreadTotal | number | Your spread revenue on the round trip, as a money amount. For information only; it is never subtracted again. |
swap | number | Overnight financing realized on this close. |
netPnl | number | pnl + swap - commissionTotal, the real effect of the trade on the balance. |
reason | string | Why it closed: manual, sl, tp, margin (stop-out), reverse, partial or admin_close (closed through the Admin API). |
openTime, closeTime | number | Milliseconds 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);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.
- Use
ctx.stateinside a handler. Do not callstorage.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
- The chart opens the socket and sends
authfirst. A request sent before auth is refused withnot_authenticated. - The server checks the token with
validateToken()inauth.jsand repliesauthResult. - The server sends one
snapshot: the account, open positions, pending orders, the latest 100 history rows and the FX pins. - The chart sends requests. Each request has a
reqIdand gets exactly oneackwith the samereqId. - The server pushes events at any time. Pushes have no
reqId. Account events go to every tab and device of that account. - The chart sends
pingevery 30 seconds (adapter optionheartbeat). The server answerspong. - If the socket drops, the chart reconnects (1 second delay, doubling up to 30 seconds), sends
authagain, gets a freshsnapshotand sendswatchSymbolagain 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" }erroris the machine code anderrmsgis the human detail. A handler writes one string,ctx.fail('code: detail'), and the server splits it at the first colon. With no detail,errmsgrepeats the code.errparamsis optional. It carries the values behind a message, for example{ "exchange": "PSX", "from": "09:32", "to": "15:30", "timezone": "Asia/Karachi" }onmarket_closed_window, so your own wording can use them as placeholders.- The chart waits 15 seconds for an
ack(adapter optionrequestTimeout), then rejects the request as timed out. - The chart repeats
accountIdinsidedata. The sample server ignores it and always uses the account the socket authenticated as.
Handshake and Control Messages
| type | Direction | Shape |
|---|---|---|
auth | chart to server | { v, type, token, auth, accountId }. auth is an optional custom payload, passed to your validateToken(token, auth, accountId). |
authResult | server to chart | { v, type, ok: true } or { type, ok: false, error }. After a failed auth the server closes the socket. |
snapshot | server to chart | { v, type, data: { account, positions, orders, history, fxOverrides } }. Sent once after every successful auth. |
ping / pong | both | { type: "ping", t } is answered with { type: "pong", t } (same timestamp). |
ack | server to chart | The reply to one request, see the envelope above. |
error | server 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.
| type | data | ack.data | Pushes that follow |
|---|---|---|---|
getAccounts | none | Array of account objects | none |
getAccount | none | One account object | none |
getPositions | none | Array of open positions | none |
getOrders | none | Array of pending orders | none |
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 first | none |
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 below | Market: 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? } } | true | positionModified |
closePosition | { positionId, closePrice, closeQty? } | true | Full close: positionClosed, historyAdded, account. Partial close (closeQty below the position qty): positionModified, historyAdded, account. |
reversePosition | { positionId, newQty?, closePrice } | true | positionClosed, historyAdded, positionOpened, account |
modifyOrder | { orderId, updates: { price?, stopLoss?, takeProfit? } } | true | orderPlaced with the full updated order |
cancelOrder | { orderId } | true | orderCancelled, 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": { ... } }.
| type | Send when | data |
|---|---|---|
account | Balance, equity or margin changed | The full account object |
positionOpened | A new position exists (market fill, pending order filled, second leg of a reverse) | The full position |
positionModified | SL, 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. |
positionClosed | A 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. |
historyAdded | Right after a close, full or partial | The full history record. A partial close has reason: "partial". |
orderPlaced | A pending order was created or modified | The full order |
orderFilled | A pending order triggered. A positionOpened follows. | { id, positionId } |
orderCancelled | A 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. |
positionsPriced | Every 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 } |
spread | You called handlers.spreadPush(symbol). Goes to every chart watching that symbol. | { symbol, ...spread rule } |
commission | You called handlers.commissionPush(symbol). Goes to every chart watching that symbol. | { symbol, commission } |
sizing | You called handlers.sizingPush(symbol). Goes to every chart watching that symbol. | { symbol, sizing: { minQty, maxQty, qtyStep } } |
fxOverrides | You called handlers.fxPush() (all clients) or handlers.fxPush(accountId) (one account) | { fxOverrides: { CCY: usdPerUnit } } |
refresh | A 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.
| Code | Reason | Meaning |
|---|---|---|
4001 | invalid_token, account_required | validateToken returned false, or it returned true but the chart sent no accountId |
4002 | invalid_account_id | The account id is not usable as a storage key (allowed: lowercase a-z, 0-9, _ and -, up to 64 characters) |
4003 | account_mismatch | The token does not own the requested account |
4004 | account_unavailable | The account record could not be loaded |
4008 | auth_timeout | No valid auth within limits.preAuthTimeoutMs (default 10000 ms) |
4029 | server_full, too_many_connections, rate_limit | limits.maxConnections reached, limits.maxConnPerIp reached, or one socket sent more than twice limits.msgRateLimitPerSec messages in one second |
Error Codes in ack.error
| Code | Returned when |
|---|---|
token_changed, account_mismatch | A request carries a different token or account id than the one the socket authenticated with |
unknown_type | No handler exists for the message type. errmsg holds the type name. |
invalid_order | placeOrder without a symbol or side |
invalid_qty | Quantity 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_symbol | The server has no profile for the symbol and the order carried none |
spot_long_only | Sell to open, or reverse, on a spot symbol |
insufficient_margin | Margin plus the open commission is more than the free margin |
fx_rate_unavailable | No exchange rate between the symbol currency and the account currency, so margin cannot be computed |
max_open_positions, max_pending_orders | The maxOpenPositions or maxPendingOrders cap in config.js is reached. errmsg states the limit. |
no_price | The server has no live price for the symbol, so a market order is not filled |
market_closed, market_status_unknown | Market order while the live feed session is closed, or not reported yet (only with enforceMarketHours on) |
market_closed_suspended, market_closed_dayoff, market_closed_window | Outside your own trading window (settings/trading-hours.json). Applies to market orders, manual close, reverse and break-even. Carries errparams. |
invalid_sl_tp | SL 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_trigger | modifyPosition: 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_found | Unknown positionId or orderId |
missing_symbol | watchSymbol without a symbol |
internal_error | A 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.
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.
- Auth first. Bind the socket to the account the token owns. Never trust the
accountIdin a later request. The sample refuses a mismatch withaccount_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, thenhistoryAdded, thenaccount. 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 exampleswap) is stored on the position too. - Orders are never replayed. While disconnected, the chart rejects
placeOrder,modifyPosition,modifyOrder,closePosition,reversePositionandcancelOrderlocally. 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)inevents.js, even when the customer has no tab open. A refused request calls it with typerequestRejectedand{ 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.
| Option | Default | What it does |
|---|---|---|
displayMode | 'normal' | 'chartonly' hides all chart UI and shows only the chart. |
enableSidebar, enableToolbar, enableRangeSelector | true | Left drawing-tools bar, top toolbar, bottom time range bar. |
enableSearch, enableIndicators, enableMultiChart, enableReplay, enableCapture, enableUndoRedo, enableTemplates, enableSettings, enableSymbolInfo, enableChartTypeSelector, enableTimeframeSelector | true | One switch per toolbar control: symbol search, indicators, multi-chart layouts, replay, screenshot, undo/redo, templates, settings, symbol info, chart type and timeframe. |
searchModalTabs | null (all) | Which asset tabs the symbol search shows, with their labels: { fav: 'Fav', forex: 'Forex', crypto: 'Crypto' }. |
searchAllowSymbols / searchExcludeSymbols | null | Only these tickers appear in search, or these tickers are hidden. Format EXCHANGE:SYMBOL. |
searchAllowExchanges | not set | Restricts 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.
| Option | Default | What 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. |
layoutLock | false | true removes the layout switch buttons. When false, the layout the user picked last is saved in the browser and wins over layout. |
rightWidth / bottomHeight | 280 / 220 | Starting panel sizes in px. A size the user drags to is saved in the browser and wins. |
tabs | all six | Tabs 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. |
positionActions | all true | { partialClose, closeAll, reverse, breakEven }. false removes that action. reverse is always hidden on spot holdings. |
orderActions | all true | { modify, cancelAll }: the edit button on a pending order and the Cancel All button. |
confirmOrders | false | Show a confirmation dialog before an order is sent. Its "Don't show again" checkbox is saved per account in the browser. |
forceConfirm | false | With confirmOrders: true, removes the "Don't show again" checkbox so the user cannot opt out. |
templates | built-in | URLs of your panel HTML: { positions, orders, history, balanceHistory, account, yourCustomTabKey }. The order form has no template; style it with CSS. |
onEditPosition / onClosePosition | not set | Your 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. |
qtySizing | built-in | Minimum, maximum and step of the order size, per asset type or per symbol. See below. |
errorMessages | built-in English | Your 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. |
commission | null | Fallback 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 key | Built-in columns (data-field) | tbody class |
|---|---|---|
positions | symbol, side, qty, avgPrice, currentPrice, stopLoss, takeProfit, pnl, pnlPercent, openTime | fcs-positions-tbody |
orders | symbol, type, side, qty, price, stopLoss, takeProfit, status, createdAt | fcs-orders-tbody |
history | symbol, side, qty, openPrice, closePrice, pnl, commissionTotal, spreadTotal, netPnl, reason, openTime, closeTime, duration | fcs-history-tbody |
balanceHistory | time, type, note, amount, balance | fcs-bh-tbody |
| custom tab key | none; each column prints the raw value | fcs-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
tabsas 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(DemoorLive). - Rejection text: the
errorMessagesmap, 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, setdisplaySymbolon 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.
| Field | Meaning |
|---|---|
base | 'feed' (default) starts from the market ask and bid. 'close' builds the quote around the candle close. 'raw' = no change. |
spreadPips | Widens each side. 10 = ask +10 pips, bid -10 pips. The pip size comes from the symbol. |
spread / spreadPercent | The same in price units, or as a fraction of price (0.001 = 0.1%). Use price units only inside perSymbol. |
markupPips / markup / markupPercent | Shifts both sides together. Can be negative. |
perSymbol | Overrides 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.
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 key | What it earns | Per-customer override | Live change |
|---|---|---|---|
askBid | Spread and markup on the quote and on market fills and closes | account.askBid | Yes |
commission | A fee per trade side | account.commission | Yes |
commissionOnClose | true charges the fee on open and on close, false on open only | No | Yes |
swap | Overnight financing on positions held past the daily rollover | account.swapFree: true skips swap | No (restart) |
fxMarkup | A fee on the currency conversion rate | No | No (restart) |
brokerCurrency | Nothing. It is the currency your income is restated in for your own books | No | Yes |
"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| Field | Meaning |
|---|---|
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. |
spreadPips | Amount added to each side, in pips. Works on every symbol. |
spread | Amount 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). |
spreadPercent | Amount added to each side, as a fraction of price. 0.0001 is 0.01% each side. |
markup, markupPips, markupPercent | Shifts both sides in the same direction. May be negative. |
byType | One rule for a whole asset class, for example forex, crypto, stock. |
perSymbol | One 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: 1on 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: 15onBINANCE:BTCUSDT: ask +15 and bid -15. The quote is 30 wider.spreadPercent: 0.0005on 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.
| type | How it is calculated | Example |
|---|---|---|
per_trade | Flat value per order, any size | value: 1: 1 share or 1000 shares both pay 1.00 |
per_lot | value * (quantity * contract size / 100000) | value: 7: 0.1 lot EURUSD pays 0.70 |
per_unit | value * quantity | value: 0.02: 10 shares pay 0.20 |
percent | value% of the notional (quantity * contract size * price), converted to the account currency | value: 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: trueThe 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.
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.
- Broker A - no spread, 1 per lot on forex, 0.1% on crypto:
askBid: { base: 'feed', spreadPips: 0 }andcommission: { 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.
| Control | Where | Value in the sample |
|---|---|---|
leverage, leverageByType, leverageBySymbol | Account record, per customer | A new sample account gets leverage: 100 |
tradingMode | config.js | stock is spot, everything else margin |
stopOutLevel | config.js | 50 |
negativeBalanceProtection | config.js | true |
maxOpenPositions, maxPendingOrders | config.js | 20 and 20 |
qtySizing | config.js | Empty: the feed profile's own limits apply |
enforceMarketHours | config.js | true |
tradingHours | config.js plus settings/trading-hours.json | enabled: 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 marginContract 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_margininstead 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 * 100The 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' }
}| Mode | Leverage | Direction | Stop-out |
|---|---|---|---|
spot | Always 1:1 | Buy 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 record | Buy and sell | Applies |
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 withmax_open_positionsonce the account holds this many open positions.maxPendingOrders: a new limit or stop order is rejected withmax_pending_ordersonce the account has this many working orders.qtySizing:minQty,maxQtyandqtyStepinbyTypeorbySymbol, for examplebySymbol: { 'TSE:7203': { minQty: 100, qtyStep: 100 } }. The rule for the symbol on screen is sent to the order form on thewatchSymbolreply. Fields you leave out fall back to the feed profile. Callhandlers.sizingPush(symbol)after a change to update open charts.- The server rejects a quantity outside the feed profile's
minQty,maxQtyorqtyStepwithinvalid_qty(defaults 0.0001, 100000 and 0.0001). This check reads the feed profile, notqtySizing, so add your own check inhandlers.jsif the server must enforce yourqtySizingvalues. - 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.
| Gate | Configured in | Applies to | Reject codes |
|---|---|---|---|
| 1. Your own trading window | tradingHours in config.js, rules in settings/trading-hours.json | Market orders, closing a position (also a partial close), reversing, and moving the stop-loss to break-even | market_closed_suspended, market_closed_dayoff, market_closed_window |
| 2. The exchange session from the live feed | enforceMarketHours in config.js | Market orders | market_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"] }
} }| Field | Meaning |
|---|---|
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. |
tradeoff | Non-trading weekdays, as names ("saturday") or numbers (0 = Sunday to 6 = Saturday). |
timezone | IANA 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 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.
- Set
leverageon every account record. A missing value is treated as 1:1. - Replace the example exchanges in
settings/trading-hours.jsonwith your own hours and time zones. Settimezonewhenever the server is not in the exchange's country. - Keep
enforceMarketHours: truefor 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.jsonan override replaces the wholeconfig.jskey. 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 option | adapter: 'demo' | adapter: { type: 'ws', url, token, accountId } |
| Who it is for | A visitor with no account: landing page, product tour, your own evaluation of the UI | A signed-up client practising before funding, on their own real settings |
| State lives in | The browser, in localStorage under the key fcs_demo_trading | Your server or database, as a normal account record |
| Engine | Computed in the browser | The server engine: same SL/TP, margin stop-out, spread, commission, FX and per-user leverage as a live account |
| SL/TP with the tab closed | No. Stops and pending orders are only checked while the tab is open | Yes. This is the reason tier 2 exists |
| Server account | None. Nothing to come back to on another device | A normal account with its own id |
| Starting money | Fixed seed: 10,000 USD, leverage 1:100, account id demo, name Demo Account | Whatever 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(), socore/price-engine.jsindexes it exactly like a live account. handlers.jsand the files incore/contain no demo-mode branch.- Your
validateToken()inauth.jsmust 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 record | Read as |
|---|---|
"isDemo": true | Demo |
"isDemo": false | Live |
missing, null, "true" (a string), anything else | Live (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.
| Field | Set to |
|---|---|
id | The 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 |
name | The client's name plus a marker, for example "Client Name (Demo)" |
isDemo | true |
ownerId | The live account id, so your portal can link a demo to its owner. The server does not read it |
createdAt | Creation time in milliseconds since epoch, for your portal to list demos and age them out. The server does not read it |
balance, equity, freeMargin | The practice amount |
margin, realizedPnl, maxDrawdown | 0 |
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| Where | How |
|---|---|
| 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 panel | Add <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 it | Override .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 USDIt 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/resetwith{ "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 totarget(default10000). Oneresetline 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_allwith{ "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.jshas no delete function and the server has no expiry job.createdAtis 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"}'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.
- Which tier:
adapter: 'demo'for anonymous visitors, or awsadapter with the demoaccountIdfor 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()inauth.jsaccepts 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
isDemoaccounts from revenue, asbroker-report.jsdoes - 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)
- Set your own FCS API keys. Fill
wsKeyandrestKey, or exportFCS_WS_KEYandFCS_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. - Set your real pricing. Review
commission,commissionOnClose,askBid(spread),swapandfxMarkup.
Reason: the server enforces these values, and they are what your customers are charged. The shipped values are examples. - Set your instrument rules. Review
tradingMode(which symbols arespotand which aremargin) andqtySizing(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. - Set your risk limits. Review
stopOutLevel,negativeBalanceProtection,maxOpenPositionsandmaxPendingOrders.
Reason: the server enforces them and rejects past the cap withmax_open_positionsormax_pending_orders. - Set your trading hours. Decide
enforceMarketHoursandtradingHours.enabled, then put your own exchanges insettings/trading-hours.jsonwith atimezonefor each.
Reason: the shipped rules (PSX, BINANCE, FX) are examples. Withouttimezonethe window is measured on the server clock. - Set your currencies. Store
currencyon every account record, setbrokerCurrencyto the currency of your own books, and pin a rate infxOverridesfor any currency that has no live rate.
Reason: an order whose currency cannot be converted is rejected withfx_rate_unavailableinstead of being margined at a wrong rate. - Set the connection limits. Review
limits.behindProxy,limits.maxConnections,limits.maxConnPerIpandlimits.msgRateLimitPerSec. SetbehindProxytotrueonly when a reverse proxy on the same machine terminates SSL.
Reason: behind a proxy every customer looks like127.0.0.1, so without it the per-IP cap applies to all customers together. - Keep the live position push on. Review
positionPushMs(default 2000, 0 disables it) andpositionSaveMs(default 10000).
Reason: on a WebSocket connection the chart does not calculate position P&L. This push keeps it moving. - Turn off debug output and keep the audit log. Set
feedDebugtofalseand ship thetradeLoglines fromlogs/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
- 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. - 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. - storage.js: export
listRiskRows(). Return, in one query, the open positions and working orders of all accounts, plus each account'saskBidandcurrency.
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. - 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 keepisValidAccountId()in your storage layer.
Reason: a bad id is rejected at login withinvalid_account_idand close code 4002. - handlers.js: add only your own rules. Run your check (risk, KYC, blocked account), then call the matching
basefunction. Read the account fromctx.stateandctx.account.
Reason: an unedited copy of a base function hides later fixes from you. A secondstorage.ensureAccount()call inside a handler gives you a second object, and its changes are lost. - events.js: connect your alerts. Send email, push or webhook from
onEvent(accountId, type, data), and handle therequestRejectedtype. It may beasync. Keep it fast.
Reason: it fires for stop-loss, take-profit and stop-out closes even when the customer has no tab open. - Decide the per-user values. Carry
leverage,leverageByType,leverageBySymbol,commission,askBid,currencyandisDemoon the account record. SetisDemotofalseexplicitly on live accounts.
Reason:isDemoon the record is the only thing that marks an account as demo, andbroker-report.jsuses it to leave demo accounts out of revenue. - Maintain
account.maxDrawdownyourself.
Reason: the chart only displays this value. The sample server does not calculate it.
Deployment and Security
- Run behind TLS. Put a reverse proxy such as nginx or Caddy in front and use a
wss://address in the chart adapterurl, never plainws://.
Reason: the login token and every order travel over this socket. - Lock down the Admin API. Set a strong
ADMIN_TOKENthrough the environment, keepadminHoston127.0.0.1or an internal interface, and never openadminPortto the public.
Reason: this endpoint can add balance to any account. The shipped token is a placeholder. - Run one instance under a process supervisor. Start it with
pm2 start pm2_server.config.jsand 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. - 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.jsonat a public path of your own and pass itsmessagesobject aserrorMessages. 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. - Change live values without a restart. Put spread, commission and risk cap changes in
settings/broker-settings.jsonand keep your baseline inconfig.js.
Reason: a restart drops every customer socket. The JSON file is read again after you save it. - Upgrade by replacing
core/anddist/. Do not edit files incore/.
Reason: a release never touches your settings and business files, so an upgrade is a file replace and a restart.
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.
- 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.jsand 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
| Issue | Cause | Solution |
|---|---|---|
Exits with FCS_WS_KEY is empty | No 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 use | Another 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 error | The 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 exist | tradingHours.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 exception | An 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].
| Issue | Cause | Solution |
|---|---|---|
Close code 4001 invalid_token | validateToken() 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_required | validateToken() returned true but the chart sent no accountId. | Pass accountId in the adapter, or return { accountId } from validateToken(). |
Close code 4002 invalid_account_id | The 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_mismatch | The 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_unavailable | The 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_timeout | No 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 once | too_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 account | The 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
| Issue | Cause | Solution |
|---|---|---|
Market orders fail with no_price | The 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_unknown | The 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_suspended | The 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" message | There 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_connected | The 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 effect | A 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 wording | errorMessages 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 account | The 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
| Issue | Cause | Solution |
|---|---|---|
| Position P&L freezes when the chart shows another symbol | The 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 update | Your handler changed the account state without a broadcast. | Call ctx.broadcast(type, data) after every state change. |
| Changes made in a handler are lost | The 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 open | listAccountIds() 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 grow | The 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_found | The 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. |
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.
- 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: trueinconfig.jsto 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
errorfield of a rejected request for the machine code anderrmsgfor the detail. - Handle the
requestRejectedtype inevents.jsto 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.
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.
- 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
